diff --git a/CODEBASE.md b/CODEBASE.md index bb7fbbf..8defb4f 100644 --- a/CODEBASE.md +++ b/CODEBASE.md @@ -120,7 +120,9 @@ New subcommand? Copy a sibling in the target group, wire it in that group's `Project`, `Section`, `User`. Paginated response shape `{ results, nextCursor }` lives in `pagination.ts`. - **`api/` siblings** — `filters.ts`, `workspaces.ts`, `notifications.ts`, - `reminders.ts`, `stats.ts`, `user-settings.ts`, `uploads.ts` + `reminders.ts`, `stats.ts`, `user-settings.ts`, `uploads.ts`, + `view-options.ts` (per-view sorting/grouping, read straight from `/sync` + because the SDK's schema rejects the API's `object_id: null`) - **`auth.ts`** — read-side resolver: `resolveActiveUser`, `getApiToken`, `probeApiToken`, `getAuthMetadata`, `listStoredUsers`, `NoTokenError`. All write/clear paths go through `auth-store.ts`. @@ -169,6 +171,10 @@ New subcommand? Copy a sibling in the target group, wire it in that group's `commentUrl`, `filterUrl` - **`task-list.ts`** — `fetchProjects`, `filterByWorkspaceOrPersonal`, `parsePriority`, `PRIORITY_CHOICES` (`"p1"`–`"p4"`; internally p1→4, p4→1) +- **`task-sort.ts`** — client-side task ordering that matches the Todoist + apps: `sortTasks`, `taskSortFromViewOptions`, `buildProjectOrder`, + `queryUsesDates`, `TASK_SORT_FIELDS`. The API returns storage order, so + every list view sorts locally. - **`pagination.ts`** — `paginate()`, `LIMITS` (tasks: 300, projects: 50, …) - **`completion.ts`** — `parseCompLine`, `getCompletions`, `withCaseInsensitiveChoices`, `withUnvalidatedChoices` (Commander tree-walker) diff --git a/src/lib/api/view-options.test.ts b/src/lib/api/view-options.test.ts new file mode 100644 index 0000000..0d40aeb --- /dev/null +++ b/src/lib/api/view-options.test.ts @@ -0,0 +1,186 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../auth.js', () => ({ + getApiToken: vi.fn(async () => 'test-token'), +})) + +vi.mock('../usage-tracking.js', () => ({ + fetchTodoist: vi.fn(), +})) + +import { fetchTodoist } from '../usage-tracking.js' +import { fetchViewOptions, fetchViewOptionsSafely, findViewOptions } from './view-options.js' +import type { SavedViewOptions } from './view-options.js' + +const mockFetchTodoist = vi.mocked(fetchTodoist) + +function respondWith(payload: unknown, ok = true): void { + mockFetchTodoist.mockResolvedValue({ + ok, + status: ok ? 200 : 500, + statusText: ok ? 'OK' : 'Internal Server Error', + json: async () => payload, + } as unknown as Response) +} + +function makeSaved(overrides: Partial): SavedViewOptions { + return { + viewType: 'FILTER', + objectId: 'filter-1', + sortedBy: null, + sortOrder: null, + groupedBy: null, + viewMode: 'LIST', + ...overrides, + } +} + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('fetchViewOptions', () => { + it('maps the sync payload to camelCase', async () => { + respondWith({ + view_options: [ + { + view_type: 'FILTER', + object_id: 'filter-1', + sorted_by: 'PRIORITY', + sort_order: 'DESC', + grouped_by: 'LABEL', + view_mode: 'LIST', + is_deleted: false, + }, + ], + }) + + await expect(fetchViewOptions()).resolves.toEqual([ + { + viewType: 'FILTER', + objectId: 'filter-1', + sortedBy: 'PRIORITY', + sortOrder: 'DESC', + groupedBy: 'LABEL', + viewMode: 'LIST', + }, + ]) + }) + + // The API sends null here for Today and Upcoming, which is what stops the + // SDK's typed sync from parsing this resource at all. + it('keeps rows whose object_id is null', async () => { + respondWith({ + view_options: [ + { view_type: 'UPCOMING', object_id: null, sort_order: 'ASC', is_deleted: false }, + ], + }) + + const [upcoming] = await fetchViewOptions() + expect(upcoming).toMatchObject({ viewType: 'UPCOMING', objectId: null, sortOrder: 'ASC' }) + }) + + it('drops deleted rows and rows with no view type', async () => { + respondWith({ + view_options: [ + { view_type: 'FILTER', object_id: 'gone', is_deleted: true }, + { object_id: 'no-type' }, + null, + { view_type: 'FILTER', object_id: 'kept' }, + ], + }) + + const results = await fetchViewOptions() + expect(results.map((entry) => entry.objectId)).toEqual(['kept']) + }) + + // The Sync API can ship a new enum member before the SDK types know it. + it('nulls enum values the SDK does not recognise', async () => { + respondWith({ + view_options: [ + { + view_type: 'FILTER', + object_id: 'filter-1', + sorted_by: 'VIBES', + sort_order: 'SIDEWAYS', + grouped_by: 'PHASE_OF_MOON', + view_mode: 'HOLOGRAM', + }, + ], + }) + + const [entry] = await fetchViewOptions() + expect(entry).toEqual({ + viewType: 'FILTER', + objectId: 'filter-1', + sortedBy: null, + sortOrder: null, + groupedBy: null, + viewMode: null, + }) + }) + + it('drops rows whose view type is not one we can ask for', async () => { + respondWith({ + view_options: [ + { view_type: 'INBOX_ZERO_ZONE', object_id: 'x' }, + { view_type: 'FILTER', object_id: 'kept' }, + ], + }) + + const results = await fetchViewOptions() + expect(results.map((entry) => entry.objectId)).toEqual(['kept']) + }) + + it('returns an empty list when the payload has no view options', async () => { + respondWith({}) + await expect(fetchViewOptions()).resolves.toEqual([]) + }) + + it('throws on a failed request', async () => { + respondWith({}, false) + await expect(fetchViewOptions()).rejects.toThrow('HTTP 500') + }) +}) + +describe('fetchViewOptionsSafely', () => { + it('swallows failures so a view still renders', async () => { + mockFetchTodoist.mockRejectedValue(new Error('offline')) + await expect(fetchViewOptionsSafely()).resolves.toEqual([]) + }) +}) + +describe('findViewOptions', () => { + const saved = [ + makeSaved({ viewType: 'PROJECT', objectId: 'shared-id' }), + makeSaved({ viewType: 'FILTER', objectId: 'filter-1', sortedBy: 'DUE_DATE' }), + makeSaved({ viewType: 'WORKSPACE_FILTER', objectId: 'filter-2', sortedBy: 'PRIORITY' }), + makeSaved({ viewType: 'UPCOMING', objectId: null, sortedBy: 'DEADLINE' }), + ] + + it('matches on view type and object id together', () => { + expect( + findViewOptions(saved, { viewTypes: ['FILTER'], objectId: 'filter-1' })?.sortedBy, + ).toBe('DUE_DATE') + expect( + findViewOptions(saved, { + viewTypes: ['FILTER', 'WORKSPACE_FILTER'], + objectId: 'filter-2', + })?.sortedBy, + ).toBe('PRIORITY') + }) + + it('does not match an id saved under another view type', () => { + expect( + findViewOptions(saved, { viewTypes: ['FILTER'], objectId: 'shared-id' }), + ).toBeUndefined() + }) + + it('finds singleton views, which have no object id', () => { + expect(findViewOptions(saved, { viewTypes: ['UPCOMING'] })?.sortedBy).toBe('DEADLINE') + }) + + it('returns undefined when nothing matches', () => { + expect(findViewOptions(saved, { viewTypes: ['FILTER'], objectId: 'nope' })).toBeUndefined() + }) +}) diff --git a/src/lib/api/view-options.ts b/src/lib/api/view-options.ts new file mode 100644 index 0000000..7ec4952 --- /dev/null +++ b/src/lib/api/view-options.ts @@ -0,0 +1,139 @@ +import { + GROUPED_BY_OPTIONS, + SORT_ORDERS, + SORTED_BY_OPTIONS, + VIEW_MODES, + VIEW_TYPES, +} from '@doist/todoist-sdk' +import type { GroupedBy, SortedBy, SortOrder, ViewMode, ViewType } from '@doist/todoist-sdk' +import { getApiToken } from '../auth.js' +import { getLogger } from '../logger.js' +import { fetchTodoist } from '../usage-tracking.js' + +const SYNC_ENDPOINT = 'https://api.todoist.com/api/v1/sync' + +/** + * The presentation settings the Todoist apps store per view (project, label, + * filter, Today, Upcoming): list/board/calendar, grouping, and sorting. + * + * Named `SavedViewOptions` so it doesn't collide with `ViewOptions` in + * `src/lib/options.ts`, which is the CLI's own output-flag bag. + */ +export interface SavedViewOptions { + viewType: ViewType + /** `null` for the singleton views (Today, Upcoming) that have no object. */ + objectId: string | null + sortedBy: SortedBy | null + sortOrder: SortOrder | null + groupedBy: GroupedBy | null + viewMode: ViewMode | null +} + +interface RawViewOptions { + view_type?: unknown + object_id?: unknown + sorted_by?: unknown + sort_order?: unknown + grouped_by?: unknown + view_mode?: unknown + is_deleted?: unknown +} + +function asString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null +} + +/** + * Read an enum value only if it is one the SDK still knows about. + * + * The Sync API can grow a new member before the SDK types catch up, and a + * value we don't recognise is better dropped than asserted into a type that + * then lies to everything downstream. An unknown `sorted_by` reads as "no + * saved sort", which lands the caller on Todoist's default ordering. + */ +function asMember(value: unknown, allowed: readonly T[]): T | null { + const candidate = asString(value) + return candidate && (allowed as readonly string[]).includes(candidate) ? (candidate as T) : null +} + +function parseViewOptions(raw: RawViewOptions): SavedViewOptions | null { + // A view type we can't name is one no caller can ask for, so the row goes. + const viewType = asMember(raw.view_type, VIEW_TYPES) + if (!viewType || raw.is_deleted === true) return null + + return { + viewType, + objectId: asString(raw.object_id), + sortedBy: asMember(raw.sorted_by, SORTED_BY_OPTIONS), + sortOrder: asMember(raw.sort_order, SORT_ORDERS), + groupedBy: asMember(raw.grouped_by, GROUPED_BY_OPTIONS), + viewMode: asMember(raw.view_mode, VIEW_MODES), + } +} + +/** + * Read every saved view option via the Sync API. + * + * This goes to `/sync` directly instead of through `api.sync()` for one + * reason: `ViewOptionsSchema` types `object_id` as a required string, while + * the API returns `null` for the singleton views (Today, Upcoming), so a typed + * sync asking for `view_options` throws for anyone who has customised either + * one. Making that single field nullable in the SDK is the whole fix. Once it + * ships, this reader can drop back to `api.sync()` or move into the SDK + * outright, and the enum handling below goes with it. + */ +export async function fetchViewOptions(): Promise { + const token = await getApiToken() + const response = await fetchTodoist(SYNC_ENDPOINT, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + sync_token: '*', + resource_types: JSON.stringify(['view_options']), + }), + }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status} ${response.statusText}`) + } + + const data = (await response.json()) as { view_options?: unknown } + if (!Array.isArray(data.view_options)) return [] + + return data.view_options + .map((entry) => parseViewOptions((entry ?? {}) as RawViewOptions)) + .filter((entry): entry is SavedViewOptions => entry !== null) +} + +/** + * Saved view options are a display nicety: when they can't be read, callers + * should still render the list rather than fail. Returns `[]` on any error and + * leaves a breadcrumb behind `-vv`. + */ +export async function fetchViewOptionsSafely(): Promise { + try { + return await fetchViewOptions() + } catch (error) { + getLogger().detail('failed to load saved view options', { + error: error instanceof Error ? error.message : String(error), + }) + return [] + } +} + +/** + * Find the saved options for one view, e.g. the FILTER view of a filter id. + * `objectId` defaults to `null` so the singleton views (Today, Upcoming), + * which the API stores with no object, can be looked up the same way. + */ +export function findViewOptions( + viewOptions: SavedViewOptions[], + { viewTypes, objectId = null }: { viewTypes: ViewType[]; objectId?: string | null }, +): SavedViewOptions | undefined { + return viewOptions.find( + (entry) => entry.objectId === objectId && viewTypes.includes(entry.viewType), + ) +} diff --git a/src/lib/task-sort.test.ts b/src/lib/task-sort.test.ts new file mode 100644 index 0000000..5ec6e3b --- /dev/null +++ b/src/lib/task-sort.test.ts @@ -0,0 +1,478 @@ +import { describe, expect, it } from 'vitest' +import type { Project, Task } from './api/core.js' +import type { SavedViewOptions } from './api/view-options.js' +import { CliError } from './errors.js' +import { + buildProjectOrder, + defaultDirectionFor, + formatTaskSort, + parseTaskSortDirection, + parseTaskSortField, + queryUsesDates, + sortNeedsCollaborators, + sortNeedsProjects, + sortTasks, + taskSortFromViewOptions, +} from './task-sort.js' + +function makeTask(overrides: Partial & { id: string }): Task { + return { + content: overrides.id, + priority: 1, + projectId: 'proj-1', + childOrder: 0, + due: null, + deadline: null, + addedAt: new Date('2026-01-01T00:00:00Z'), + responsibleUid: null, + labels: [], + ...overrides, + } as Task +} + +function makeProject(overrides: Partial & { id: string }): Project { + return { + name: overrides.id, + childOrder: 0, + parentId: null, + inboxProject: false, + ...overrides, + } as Project +} + +function ids(tasks: Task[]): string[] { + return tasks.map((task) => task.id) +} + +describe('sortTasks', () => { + it('leads with priority for a filter that does not query dates', () => { + const tasks = [ + makeTask({ id: 'p4' }), + makeTask({ id: 'p1', priority: 4 }), + makeTask({ id: 'p3', priority: 2 }), + ] + + expect(ids(sortTasks(tasks, { field: 'default', direction: 'asc' }))).toEqual([ + 'p1', + 'p3', + 'p4', + ]) + }) + + it('breaks priority ties by due date, then deadline, then project order', () => { + const tasks = [ + makeTask({ id: 'no-date', projectId: 'proj-2' }), + makeTask({ id: 'deadline', deadline: { date: '2026-03-01', lang: 'en' } }), + makeTask({ id: 'later', due: { date: '2026-02-02', string: '', isRecurring: false } }), + makeTask({ id: 'sooner', due: { date: '2026-02-01', string: '', isRecurring: false } }), + makeTask({ id: 'first-project' }), + ] + + const order = buildProjectOrder([ + makeProject({ id: 'proj-1', childOrder: 0 }), + makeProject({ id: 'proj-2', childOrder: 1 }), + ]) + + expect(ids(sortTasks(tasks, { field: 'default', direction: 'asc' }, order))).toEqual([ + 'sooner', + 'later', + 'deadline', + 'first-project', + 'no-date', + ]) + }) + + it('leads with date for a filter that queries dates', () => { + const tasks = [ + makeTask({ + id: 'p1-later', + priority: 4, + due: { date: '2026-02-02', string: '', isRecurring: false }, + }), + makeTask({ + id: 'p4-sooner', + due: { date: '2026-02-01', string: '', isRecurring: false }, + }), + ] + + expect( + ids(sortTasks(tasks, { field: 'default', direction: 'asc' }, { dateDriven: true })), + ).toEqual(['p4-sooner', 'p1-later']) + }) + + it('falls back to the deadline when a date-driven task has no due date', () => { + const tasks = [ + makeTask({ + id: 'due-later', + due: { date: '2026-02-03', string: '', isRecurring: false }, + }), + makeTask({ id: 'deadline-sooner', deadline: { date: '2026-02-01', lang: 'en' } }), + ] + + expect( + ids(sortTasks(tasks, { field: 'default', direction: 'asc' }, { dateDriven: true })), + ).toEqual(['deadline-sooner', 'due-later']) + }) + + it('sorts all-day tasks ahead of timed tasks on the same day', () => { + const tasks = [ + makeTask({ + id: 'timed', + due: { + date: '2026-02-01', + datetime: '2026-02-01T09:00:00', + string: '', + isRecurring: false, + }, + }), + makeTask({ + id: 'all-day', + due: { date: '2026-02-01', string: '', isRecurring: false }, + }), + ] + + expect(ids(sortTasks(tasks, { field: 'date', direction: 'asc' }))).toEqual([ + 'all-day', + 'timed', + ]) + }) + + it('orders a zoned datetime by the instant it falls on', () => { + // Noon in Tokyo is 03:00 UTC, so it lands between the all-day task and + // a floating 09:00, which the CLI reads in the local zone (UTC here). + const tasks = [ + makeTask({ + id: 'floating-9am', + due: { + date: '2026-02-01', + datetime: '2026-02-01T09:00:00', + string: '', + isRecurring: false, + }, + }), + makeTask({ + id: 'tokyo-noon', + due: { + date: '2026-02-01', + datetime: '2026-02-01T12:00:00+09:00', + timezone: 'Asia/Tokyo', + string: '', + isRecurring: false, + }, + }), + makeTask({ + id: 'all-day', + due: { date: '2026-02-01', string: '', isRecurring: false }, + }), + ] + + expect(ids(sortTasks(tasks, { field: 'date', direction: 'asc' }))).toEqual([ + 'all-day', + 'tokyo-noon', + 'floating-9am', + ]) + }) + + it('parks undated tasks at the end, and at the front when reversed', () => { + const tasks = [ + makeTask({ id: 'undated' }), + makeTask({ id: 'dated', due: { date: '2026-02-01', string: '', isRecurring: false } }), + ] + + expect(ids(sortTasks(tasks, { field: 'date', direction: 'asc' }))).toEqual([ + 'dated', + 'undated', + ]) + expect(ids(sortTasks(tasks, { field: 'date', direction: 'desc' }))).toEqual([ + 'undated', + 'dated', + ]) + }) + + it('sorts priority p1 first when descending', () => { + const tasks = [ + makeTask({ id: 'p3', priority: 2 }), + makeTask({ id: 'p1', priority: 4 }), + makeTask({ id: 'p4' }), + ] + + expect(ids(sortTasks(tasks, { field: 'priority', direction: 'desc' }))).toEqual([ + 'p1', + 'p3', + 'p4', + ]) + expect(ids(sortTasks(tasks, { field: 'priority', direction: 'asc' }))).toEqual([ + 'p4', + 'p3', + 'p1', + ]) + }) + + it('keeps secondary criteria in default order when the primary is reversed', () => { + const tasks = [ + makeTask({ id: 'b-p4', content: 'Beta' }), + makeTask({ id: 'a-p1', content: 'Alpha', priority: 4 }), + makeTask({ id: 'a-p4', content: 'Alpha' }), + ] + + // Z-A on the name, but the two "Alpha" tasks stay p1 before p4. + expect(ids(sortTasks(tasks, { field: 'name', direction: 'desc' }))).toEqual([ + 'b-p4', + 'a-p1', + 'a-p4', + ]) + }) + + it('sorts by name, date added, and deadline', () => { + const tasks = [ + makeTask({ + id: 'charlie', + content: 'Charlie', + addedAt: new Date('2026-01-03T00:00:00Z'), + deadline: { date: '2026-05-01', lang: 'en' }, + }), + makeTask({ + id: 'alpha', + content: 'alpha', + addedAt: new Date('2026-01-02T00:00:00Z'), + deadline: { date: '2026-04-01', lang: 'en' }, + }), + makeTask({ + id: 'bravo', + content: 'Bravo', + addedAt: new Date('2026-01-01T00:00:00Z'), + deadline: { date: '2026-06-01', lang: 'en' }, + }), + ] + + expect(ids(sortTasks(tasks, { field: 'name', direction: 'asc' }))).toEqual([ + 'alpha', + 'bravo', + 'charlie', + ]) + expect(ids(sortTasks(tasks, { field: 'date-added', direction: 'asc' }))).toEqual([ + 'bravo', + 'alpha', + 'charlie', + ]) + expect(ids(sortTasks(tasks, { field: 'deadline', direction: 'asc' }))).toEqual([ + 'alpha', + 'charlie', + 'bravo', + ]) + }) + + it('sorts by assignee name and parks unassigned tasks last', () => { + const tasks = [ + makeTask({ id: 'unassigned' }), + makeTask({ id: 'zoe', responsibleUid: 'user-z' }), + makeTask({ id: 'ana', responsibleUid: 'user-a' }), + ] + const names: Record = { 'user-a': 'Ana', 'user-z': 'Zoe' } + + expect( + ids( + sortTasks( + tasks, + { field: 'assignee', direction: 'asc' }, + { + assigneeName: (task) => + task.responsibleUid ? names[task.responsibleUid] : null, + }, + ), + ), + ).toEqual(['ana', 'zoe', 'unassigned']) + }) + + it('moves unassigned tasks to the front when the assignee sort is reversed', () => { + const tasks = [ + makeTask({ id: 'unassigned' }), + makeTask({ id: 'zoe', responsibleUid: 'user-z' }), + makeTask({ id: 'ana', responsibleUid: 'user-a' }), + ] + const names: Record = { 'user-a': 'Ana', 'user-z': 'Zoe' } + + expect( + ids( + sortTasks( + tasks, + { field: 'assignee', direction: 'desc' }, + { + assigneeName: (task) => + task.responsibleUid ? names[task.responsibleUid] : null, + }, + ), + ), + ).toEqual(['unassigned', 'zoe', 'ana']) + }) + + it('sorts by workspace, personal projects first', () => { + const order = buildProjectOrder([ + makeProject({ id: 'personal-1', childOrder: 0 }), + makeProject({ id: 'ws-a-1', workspaceId: 'ws-a', childOrder: 0 }), + makeProject({ id: 'ws-b-1', workspaceId: 'ws-b', childOrder: 0 }), + ]) + const tasks = [ + makeTask({ id: 'in-ws-b', projectId: 'ws-b-1' }), + makeTask({ id: 'in-personal', projectId: 'personal-1' }), + makeTask({ id: 'in-ws-a', projectId: 'ws-a-1' }), + ] + + expect(ids(sortTasks(tasks, { field: 'workspace', direction: 'asc' }, order))).toEqual([ + 'in-personal', + 'in-ws-a', + 'in-ws-b', + ]) + expect(ids(sortTasks(tasks, { field: 'workspace', direction: 'desc' }, order))).toEqual([ + 'in-ws-b', + 'in-ws-a', + 'in-personal', + ]) + }) + + it('leaves the API order untouched for "none"', () => { + const tasks = [ + makeTask({ id: 'second', priority: 1 }), + makeTask({ id: 'first', priority: 4 }), + ] + const sorted = sortTasks(tasks, { field: 'none', direction: 'asc' }) + + expect(ids(sorted)).toEqual(['second', 'first']) + // A copy, so a caller reversing the result can't reorder the input. + expect(sorted).not.toBe(tasks) + }) + + it('does not mutate the input list', () => { + const tasks = [makeTask({ id: 'p4' }), makeTask({ id: 'p1', priority: 4 })] + + sortTasks(tasks, { field: 'default', direction: 'asc' }) + + expect(ids(tasks)).toEqual(['p4', 'p1']) + }) +}) + +describe('buildProjectOrder', () => { + it('lays projects out as Inbox, personal tree, then workspaces', () => { + const order = buildProjectOrder([ + makeProject({ id: 'ws-b', workspaceId: 'ws-2', childOrder: 0 }), + makeProject({ id: 'child', parentId: 'personal', childOrder: 0 }), + makeProject({ id: 'ws-a', workspaceId: 'ws-1', childOrder: 0 }), + makeProject({ id: 'personal', childOrder: 5 }), + makeProject({ id: 'inbox', inboxProject: true, childOrder: 9 }), + ]) + + const byPosition = [...order.projectIndex.entries()] + .sort((a, b) => a[1] - b[1]) + .map(([id]) => id) + + expect(byPosition).toEqual(['inbox', 'personal', 'child', 'ws-a', 'ws-b']) + expect(order.workspaceIndex.get('personal')).toBe(0) + expect(order.workspaceIndex.get('ws-a')).toBe(1) + expect(order.workspaceIndex.get('ws-b')).toBe(2) + }) +}) + +describe('taskSortFromViewOptions', () => { + function makeViewOptions(overrides: Partial): SavedViewOptions { + return { + viewType: 'FILTER', + objectId: 'filter-1', + sortedBy: null, + sortOrder: null, + groupedBy: null, + viewMode: 'LIST', + ...overrides, + } + } + + it('reads the saved sorting', () => { + expect( + taskSortFromViewOptions(makeViewOptions({ sortedBy: 'PRIORITY', sortOrder: 'DESC' })), + ).toEqual({ field: 'priority', direction: 'desc' }) + expect( + taskSortFromViewOptions(makeViewOptions({ sortedBy: 'DUE_DATE', sortOrder: 'ASC' })), + ).toEqual({ field: 'date', direction: 'asc' }) + }) + + it('treats a missing view, a null sort, and MANUAL as the Todoist default', () => { + expect(taskSortFromViewOptions(undefined).field).toBe('default') + expect(taskSortFromViewOptions(makeViewOptions({})).field).toBe('default') + expect(taskSortFromViewOptions(makeViewOptions({ sortedBy: 'MANUAL' })).field).toBe( + 'default', + ) + }) + + it('falls back to the per-field direction when the view has none', () => { + expect(taskSortFromViewOptions(makeViewOptions({ sortedBy: 'PRIORITY' })).direction).toBe( + 'desc', + ) + expect(taskSortFromViewOptions(makeViewOptions({ sortedBy: 'DUE_DATE' })).direction).toBe( + 'asc', + ) + }) +}) + +describe('queryUsesDates', () => { + it.each([ + 'today', + 'due before: next week', + 'overdue | today', + '@work & 7 days', + 'no date', + 'deadline: today', + ])('treats %s as date-driven', (query) => { + expect(queryUsesDates(query)).toBe(true) + }) + + it.each(['##work & p4 & !subtask', '@waiting', '#Marketing & p1', 'search: invoice'])( + 'treats %s as priority-driven', + (query) => { + expect(queryUsesDates(query)).toBe(false) + }, + ) + + it('ignores date words inside project and label names', () => { + expect(queryUsesDates('#May Launch')).toBe(false) + expect(queryUsesDates('@monday-meeting & p1')).toBe(false) + }) + + it('ignores date words inside a search term', () => { + expect(queryUsesDates('search: due diligence')).toBe(false) + expect(queryUsesDates('search: today notes & p1')).toBe(false) + // The search operand ends at the operator, so a real date query still counts. + expect(queryUsesDates('search: invoice & today')).toBe(true) + }) +}) + +describe('sort option parsing', () => { + it('accepts known fields and directions case-insensitively', () => { + expect(parseTaskSortField('Priority')).toBe('priority') + expect(parseTaskSortField(' date-added ')).toBe('date-added') + expect(parseTaskSortDirection('DESC')).toBe('desc') + }) + + it('rejects unknown values with a CliError', () => { + expect(() => parseTaskSortField('due')).toThrow(CliError) + expect(() => parseTaskSortDirection('descending')).toThrow(CliError) + }) + + it('defaults priority to descending and everything else to ascending', () => { + expect(defaultDirectionFor('priority')).toBe('desc') + expect(defaultDirectionFor('date')).toBe('asc') + }) + + it('knows which sorts need extra lookups', () => { + // Every sort tie-breaks on the default hierarchy, which reads project + // order, so only the unsorted path can skip the project fetch. + expect(sortNeedsProjects('name')).toBe(true) + expect(sortNeedsProjects('assignee')).toBe(true) + expect(sortNeedsProjects('none')).toBe(false) + expect(sortNeedsCollaborators('assignee')).toBe(true) + expect(sortNeedsCollaborators('default')).toBe(false) + }) + + it('describes the applied sort', () => { + expect(formatTaskSort({ field: 'default', direction: 'asc' })).toBe('Todoist default') + expect(formatTaskSort({ field: 'priority', direction: 'desc' })).toBe('Priority (p1 first)') + }) +}) diff --git a/src/lib/task-sort.ts b/src/lib/task-sort.ts new file mode 100644 index 0000000..19831a5 --- /dev/null +++ b/src/lib/task-sort.ts @@ -0,0 +1,419 @@ +import { isWorkspaceProject } from '@doist/todoist-sdk' +import type { SortedBy, SortOrder } from '@doist/todoist-sdk' +import { parseISO } from 'date-fns/parseISO' +import type { Project, Task } from './api/core.js' +import type { SavedViewOptions } from './api/view-options.js' +import { CliError } from './errors.js' + +/** + * Client-side task ordering that mirrors the Todoist apps. + * + * The API returns tasks in storage order and every Todoist client sorts them + * locally: first by the sorting saved on the view, and when that is "Manual + * (default)", by a documented per-view-type hierarchy. Without this, `td` + * lists tasks in an order no other Todoist client shows. + * + * @see https://www.todoist.com/help/articles/default-sorting-order-for-todoist-tasks-mqmgerY7 + */ + +export const TASK_SORT_FIELDS = [ + 'default', + 'priority', + 'date', + 'deadline', + 'date-added', + 'name', + 'project', + 'assignee', + 'workspace', + 'none', +] as const + +export type TaskSortField = (typeof TASK_SORT_FIELDS)[number] + +export const TASK_SORT_DIRECTIONS = ['asc', 'desc'] as const + +export type TaskSortDirection = (typeof TASK_SORT_DIRECTIONS)[number] + +export interface TaskSort { + field: TaskSortField + direction: TaskSortDirection +} + +export const DEFAULT_TASK_SORT: TaskSort = { field: 'default', direction: 'asc' } + +/** Sync API `sorted_by` values → CLI sort fields. */ +const FIELD_BY_SORTED_BY: Record = { + MANUAL: 'default', + ALPHABETICALLY: 'name', + ASSIGNEE: 'assignee', + DUE_DATE: 'date', + DEADLINE: 'deadline', + ADDED_DATE: 'date-added', + PRIORITY: 'priority', + PROJECT: 'project', + WORKSPACE: 'workspace', +} + +const FIELD_LABELS: Record = { + default: { asc: 'Todoist default', desc: 'Todoist default' }, + priority: { asc: 'Priority (p4 first)', desc: 'Priority (p1 first)' }, + date: { asc: 'Due date (earliest first)', desc: 'Due date (latest first)' }, + deadline: { asc: 'Deadline (earliest first)', desc: 'Deadline (latest first)' }, + 'date-added': { asc: 'Date added (oldest first)', desc: 'Date added (newest first)' }, + name: { asc: 'Name (A-Z)', desc: 'Name (Z-A)' }, + project: { asc: 'Project order', desc: 'Project order (reversed)' }, + assignee: { asc: 'Assignee (A-Z)', desc: 'Assignee (Z-A)' }, + workspace: { asc: 'Workspace order', desc: 'Workspace order (reversed)' }, + none: { asc: 'None (API order)', desc: 'None (API order)' }, +} + +/** + * Todoist sorts ascending everywhere except priority, which reads p1 → p4 and + * is stored as descending. + */ +export function defaultDirectionFor(field: TaskSortField): TaskSortDirection { + return field === 'priority' ? 'desc' : 'asc' +} + +export function parseTaskSortField(value: string): TaskSortField { + const normalized = value.trim().toLowerCase() + const match = TASK_SORT_FIELDS.find((field) => field === normalized) + if (match) return match + throw new CliError('INVALID_SORT', `Invalid sort field "${value}".`, [ + `Valid fields: ${TASK_SORT_FIELDS.join(', ')}`, + ]) +} + +export function parseTaskSortDirection(value: string): TaskSortDirection { + const normalized = value.trim().toLowerCase() + const match = TASK_SORT_DIRECTIONS.find((direction) => direction === normalized) + if (match) return match + throw new CliError('INVALID_SORT_ORDER', `Invalid sort order "${value}".`, [ + `Valid orders: ${TASK_SORT_DIRECTIONS.join(', ')}`, + ]) +} + +/** The sorting a saved view applies, or the Todoist default when it has none. */ +export function taskSortFromViewOptions(viewOptions?: SavedViewOptions): TaskSort { + const sortedBy = viewOptions?.sortedBy + const field = sortedBy ? (FIELD_BY_SORTED_BY[sortedBy] ?? 'default') : 'default' + if (field === 'default') return DEFAULT_TASK_SORT + + return { field, direction: directionFromSortOrder(viewOptions?.sortOrder, field) } +} + +function directionFromSortOrder( + sortOrder: SortOrder | null | undefined, + field: TaskSortField, +): TaskSortDirection { + if (sortOrder === 'ASC') return 'asc' + if (sortOrder === 'DESC') return 'desc' + return defaultDirectionFor(field) +} + +export function formatTaskSort(sort: TaskSort): string { + return FIELD_LABELS[sort.field][sort.direction] +} + +/** + * Every sort but `none` needs the project list. 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 for, say, a name + * sort would order equal names differently from the same sort in another + * output mode. Assignee sorting needs it too, to resolve collaborators. + */ +export function sortNeedsProjects(field: TaskSortField): boolean { + return field !== 'none' +} + +/** Only assignee sorting needs collaborator names resolved. */ +export function sortNeedsCollaborators(field: TaskSortField): boolean { + return field === 'assignee' +} + +export interface ProjectOrder { + /** Project id → position in the sidebar. */ + projectIndex: Map + /** Project id → workspace bucket (0 is personal). */ + workspaceIndex: Map +} + +export interface TaskOrderContext extends Partial { + /** Assignee display name, used by assignee sorting. Unassigned sorts last. */ + assigneeName?: (task: Task) => string | null + /** + * True when the list is driven by dates: Today, Upcoming, and filters + * whose query mentions dates lead with date instead of priority. + */ + dateDriven?: boolean +} + +/** + * Lay projects out in sidebar order: Inbox, the personal tree, then each + * workspace. Workspace grouping order isn't exposed by the API, so workspaces + * are ordered by id, which is stable across runs and all a tie-break needs. + */ +export function buildProjectOrder(projects: Iterable): ProjectOrder { + const personal: Project[] = [] + const byWorkspace = new Map() + + for (const project of projects) { + if (isWorkspaceProject(project)) { + const bucket = byWorkspace.get(project.workspaceId) ?? [] + bucket.push(project) + byWorkspace.set(project.workspaceId, bucket) + } else { + personal.push(project) + } + } + + const buckets: Project[][] = [ + orderPersonalProjects(personal), + ...[...byWorkspace.keys()].sort().map((id) => orderProjectTree(byWorkspace.get(id) ?? [])), + ] + + const projectIndex = new Map() + const workspaceIndex = new Map() + let position = 0 + + for (const [bucket, projectsInBucket] of buckets.entries()) { + for (const project of projectsInBucket) { + projectIndex.set(project.id, position++) + workspaceIndex.set(project.id, bucket) + } + } + + return { projectIndex, workspaceIndex } +} + +function orderPersonalProjects(projects: Project[]): Project[] { + const inbox = projects.filter(isInboxProject) + const rest = orderProjectTree(projects.filter((project) => !isInboxProject(project))) + return [...inbox, ...rest] +} + +function isInboxProject(project: Project): boolean { + return 'inboxProject' in project && project.inboxProject === true +} + +function parentProjectId(project: Project): string | null { + return isWorkspaceProject(project) ? null : project.parentId +} + +function folderKey(project: Project): string { + return isWorkspaceProject(project) ? (project.folderId ?? '') : '' +} + +/** + * Depth-first walk of a project tree, siblings in `childOrder` order. Personal + * projects nest under a parent project; workspace projects sit in folders + * instead, so they are only kept folder-adjacent. Folder order itself is not + * on the project record. + */ +function orderProjectTree(projects: Project[]): Project[] { + const ids = new Set(projects.map((project) => project.id)) + const byParent = new Map() + + for (const project of projects) { + const parent = parentProjectId(project) + const parentId = parent && ids.has(parent) ? parent : null + const siblings = byParent.get(parentId) ?? [] + siblings.push(project) + byParent.set(parentId, siblings) + } + + for (const siblings of byParent.values()) { + siblings.sort( + (a, b) => + compareText(folderKey(a), folderKey(b)) || + compare(a.childOrder, b.childOrder) || + compareText(a.name, b.name), + ) + } + + const ordered: Project[] = [] + const visited = new Set() + + function visit(parentId: string | null): void { + for (const project of byParent.get(parentId) ?? []) { + if (visited.has(project.id)) continue + visited.add(project.id) + ordered.push(project) + visit(project.id) + } + } + + visit(null) + return ordered +} + +const NO_VALUE = Number.POSITIVE_INFINITY + +function compare(a: number, b: number): number { + if (a === b) return 0 + return a < b ? -1 : 1 +} + +function compareText(a: string, b: string): number { + return a.localeCompare(b, undefined, { sensitivity: 'base', numeric: true }) +} + +/** + * Comparable instant for a due or deadline value. + * + * `parseISO` reads date-only values and floating datetimes in the local zone + * and resolves `Z`/offset datetimes to their real instant, which puts all + * three on one axis: a task due at 09:00 in Tokyo sorts against a floating + * 09:00 by when it actually falls, and an all-day task still leads the timed + * tasks on its day. `Date.parse` can't do this because it reads date-only + * values as UTC and floating datetimes as local. + */ +function timestampOf(value: string): number { + const parsed = parseISO(value).getTime() + return Number.isNaN(parsed) ? NO_VALUE : parsed +} + +function dueValue(task: Task): number { + if (!task.due) return NO_VALUE + return timestampOf(task.due.datetime ?? task.due.date) +} + +function deadlineValue(task: Task): number { + if (!task.deadline) return NO_VALUE + return timestampOf(task.deadline.date) +} + +/** Date-driven views fall back to the deadline when a task has no due date. */ +function scheduleValue(task: Task): number { + const due = dueValue(task) + return due === NO_VALUE ? deadlineValue(task) : due +} + +function addedValue(task: Task): number { + return task.addedAt ? task.addedAt.getTime() : NO_VALUE +} + +function projectValue(task: Task, context: TaskOrderContext): number { + return context.projectIndex?.get(task.projectId) ?? NO_VALUE +} + +function workspaceValue(task: Task, context: TaskOrderContext): number { + return context.workspaceIndex?.get(task.projectId) ?? NO_VALUE +} + +/** + * The order Todoist falls back to when a view has no explicit sorting. Filters + * that query dates (and the Today/Upcoming views) lead with date and time; + * every other list leads with priority. + */ +function compareDefault(a: Task, b: Task, context: TaskOrderContext): number { + if (context.dateDriven) { + return ( + compare(scheduleValue(a), scheduleValue(b)) || + compare(b.priority, a.priority) || + compare(deadlineValue(a), deadlineValue(b)) || + compare(a.childOrder, b.childOrder) || + compare(addedValue(a), addedValue(b)) + ) + } + + return ( + compare(b.priority, a.priority) || + compare(dueValue(a), dueValue(b)) || + compare(deadlineValue(a), deadlineValue(b)) || + compare(projectValue(a, context), projectValue(b, context)) || + compare(a.childOrder, b.childOrder) + ) +} + +function compareAssignee(a: Task, b: Task, context: TaskOrderContext): number { + const nameA = context.assigneeName?.(a) ?? null + const nameB = context.assigneeName?.(b) ?? null + if (nameA === null || nameB === null) { + if (nameA === nameB) return 0 + // Unassigned tasks sit at the end, like an absent date does. + return nameA === null ? 1 : -1 + } + return compareText(nameA, nameB) +} + +function comparePrimary(field: TaskSortField, a: Task, b: Task, context: TaskOrderContext): number { + switch (field) { + case 'priority': + return compare(a.priority, b.priority) + case 'date': + return compare(dueValue(a), dueValue(b)) + case 'deadline': + return compare(deadlineValue(a), deadlineValue(b)) + case 'date-added': + return compare(addedValue(a), addedValue(b)) + case 'name': + return compareText(a.content, b.content) + case 'project': + return compare(projectValue(a, context), projectValue(b, context)) + case 'workspace': + return compare(workspaceValue(a, context), workspaceValue(b, context)) + case 'assignee': + return compareAssignee(a, b, context) + default: + return 0 + } +} + +/** + * Sort a task list the way a Todoist client would. + * + * Reversing only flips the primary criteria; the secondary criteria stay in + * their default order, and values Todoist parks at the end of an ascending + * list (no date, no assignee) move to the top when reversed. + */ +export function sortTasks(tasks: Task[], sort: TaskSort, context: TaskOrderContext = {}): Task[] { + if (sort.field === 'none') return [...tasks] + + const reverse = sort.direction === 'desc' ? -1 : 1 + + return [...tasks].sort((a, b) => { + if (sort.field !== 'default') { + const primary = comparePrimary(sort.field, a, b, context) * reverse + if (primary !== 0) return primary + } + return compareDefault(a, b, context) + }) +} + +/** + * Tokens that make a filter query date-driven, which switches the default + * ordering from priority-first to date-first. Todoist parses the query for + * real; this is a keyword match over the English filter vocabulary, and the + * only thing riding on it is which of two default orders applies. + */ +const DATE_QUERY_PATTERN = new RegExp( + [ + String.raw`\b(?:today|tomorrow|yesterday|overdue|due|dated?|datetime|deadlines?|recurring)\b`, + String.raw`\b(?:mon|tue|wed|thu|fri|sat|sun)\b`, + String.raw`\b(?:monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b`, + String.raw`\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\b`, + String.raw`\b(?:before|after)\s*:`, + String.raw`\b\d+\s*(?:days?|hours?|weeks?|months?)\b`, + String.raw`\b(?:next|last)\s+(?:week|month|year|\d+)`, + ].join('|'), + 'i', +) + +/** + * Names and free-text searches can contain date words ("#May launch", + * "search: due diligence"), so those operands are dropped before the query is + * inspected. A `search:` term runs to the next boolean operator or list comma. + */ +function stripNamedRefs(query: string): string { + return query + .replace(/"[^"]*"/g, ' ') + .replace(/'[^']*'/g, ' ') + .replace(/\bsearch\s*:[^&|(),]*/gi, ' ') + .replace(/[#@/]{1,2}[^\s&|()!,]+/g, ' ') +} + +export function queryUsesDates(query: string): boolean { + return DATE_QUERY_PATTERN.test(stripNamedRefs(query)) +}