diff --git a/docs/docs/query-blocks.md b/docs/docs/query-blocks.md index 8dc630d..69348c6 100644 --- a/docs/docs/query-blocks.md +++ b/docs/docs/query-blocks.md @@ -20,7 +20,11 @@ The query is defined as [YAML](https://yaml.org/) and there are a number of opti ### `filter` -The filter option is **required** and should be a valid [Todoist filter](https://todoist.com/help/articles/introduction-to-filters-V98wIH). Note that this must be the content of the filter, you cannot refer to a filter that already exists in your Todoist account. +The `filter` parameter is: +- **Required** when viewing active tasks (`viewCompleted: false`) +- **Optional** when viewing completed tasks (`viewCompleted: true`) + +Should be a valid [Todoist filter](https://todoist.com/help/articles/introduction-to-filters-V98wIH). Note that this must be the content of the filter, you cannot refer to a filter that already exists in your Todoist account. There are a few unsupported filters, these are tracked in [this GitHub issue](https://github.com/jamiebrynes7/obsidian-todoist-plugin/issues/34): @@ -44,6 +48,8 @@ filter: "today | overdue" The `autorefresh` option allows you to specify the number of seconds between automatic refreshes. This takes precedence over the plugin level setting. Omitting this option means the query will follow the plugin level settings. +Note: When viewing completed tasks with `autorefresh` enabled, the refresh interval must be at least 9 seconds due to Todoist API rate limits. + For example: ```` @@ -128,4 +134,47 @@ For example: filter: "today | overdue" show: none ``` -```` \ No newline at end of file +```` + +### `viewCompleted` + +The `viewCompleted` option allows you to include completed tasks in your query results. By default, this is set to `false`. When set to `true`, completed tasks will be shown according to the specified `completedSince` and `completedUntil` parameters. + +For example: + +```` +```todoist +viewCompleted: true +completedLimit: 30 +``` +```` + +### `completedLimit` + +The `completedLimit` option controls how many completed tasks to fetch. This value must not exceed 200 due to Todoist API limitations. If not specified, defaults to 30 tasks. + +For example: + +```` +```todoist +viewCompleted: true +completedLimit: 30 +``` +```` + +### `completedSince` and `completedUntil` + +These options allow you to specify a date range for completed tasks. Both parameters accept ISO datetime strings. + +- `completedSince`: Only show tasks completed after this date (inclusive) +- `completedUntil`: Only show tasks completed before this date (inclusive) + +For example: + +```` +```todoist +viewCompleted: true +completedSince: "2024-01-01T00:00:00" +completedUntil: "2024-03-31T23:59:59" +``` +```` diff --git a/plugin/src/api/domain/completedTask.ts b/plugin/src/api/domain/completedTask.ts new file mode 100644 index 0000000..91e147d --- /dev/null +++ b/plugin/src/api/domain/completedTask.ts @@ -0,0 +1,30 @@ +import type { DueDate } from "@/api/domain/dueDate"; +import type { Priority } from "@/api/domain/task"; + +export type CompletedTask = { + id: string; + task_id: string; + project_id: string; + section_id: string | null; + content: string; + completed_at: string; + note_count: number; + meta_data: string | null; + item_object: { + due?: DueDate | null; + description: string; + priority: Priority; + labels: string[]; + }; +}; + +export type CompletedTasksResponse = { + items: CompletedTask[]; +}; + +export type GetCompletedTasksParams = { + project_id?: string; + limit?: number; + until?: Date; + since?: Date; +}; diff --git a/plugin/src/api/index.ts b/plugin/src/api/index.ts index 6307684..35e1c85 100644 --- a/plugin/src/api/index.ts +++ b/plugin/src/api/index.ts @@ -1,3 +1,8 @@ +import type { + CompletedTask, + CompletedTasksResponse, + GetCompletedTasksParams, +} from "@/api/domain/completedTask"; import type { Label } from "@/api/domain/label"; import type { Project } from "@/api/domain/project"; import type { Section } from "@/api/domain/section"; @@ -10,6 +15,8 @@ import snakify from "snakify-ts"; export class TodoistApiClient { private token: string; private fetcher: WebFetcher; + private readonly syncBaseUrl = "https://api.todoist.com/sync/v9"; + private readonly restBaseUrl = "https://api.todoist.com/rest/v2"; constructor(token: string, fetcher: WebFetcher) { this.token = token; @@ -52,9 +59,57 @@ export class TodoistApiClient { return camelize(JSON.parse(response.body)) as Label[]; } + /** + * Gets a list of completed tasks from Todoist using the sync API. + */ + public async getCompletedTasks(params?: GetCompletedTasksParams): Promise { + const queryParams = new URLSearchParams(); + + if (params) { + if (params.project_id) queryParams.set("project_id", params.project_id); + if (params.limit) queryParams.set("limit", params.limit.toString()); + if (params.until) queryParams.set("until", params.until.toISOString()); + if (params.since) queryParams.set("since", params.since.toISOString()); + } + + queryParams.set("annotate_items", "true"); + + const url = `${this.syncBaseUrl}/completed/get_all${ + queryParams.toString() ? `?${queryParams.toString()}` : "" + }`; + + debug({ + msg: "Sending Todoist API request", + context: Object.fromEntries(queryParams.entries()), + }); + + const response = await this.fetcher.fetch({ + url, + method: "GET", + headers: { + Authorization: `Bearer ${this.token}`, + }, + }); + + debug({ + msg: "Received Todoist API response", + context: response, + }); + + if (response.statusCode >= 400) { + throw new TodoistApiError( + { url, method: "GET", headers: { Authorization: `Bearer ${this.token}` } }, + response, + ); + } + + const data = JSON.parse(response.body) as CompletedTasksResponse; + return data.items; + } + private async do(path: string, method: string, json?: object): Promise { const params: RequestParams = { - url: `https://api.todoist.com/rest/v2${path}`, + url: `${this.restBaseUrl}${path}`, method: method, headers: { Authorization: `Bearer ${this.token}`, diff --git a/plugin/src/data/index.ts b/plugin/src/data/index.ts index c68f43d..c8c23d9 100644 --- a/plugin/src/data/index.ts +++ b/plugin/src/data/index.ts @@ -1,4 +1,5 @@ import { type TodoistApiClient, TodoistApiError } from "@/api"; +import type { CompletedTask, GetCompletedTasksParams } from "@/api/domain/completedTask"; import type { Label, LabelId } from "@/api/domain/label"; import type { Project, ProjectId } from "@/api/domain/project"; import type { Section, SectionId } from "@/api/domain/section"; @@ -6,6 +7,7 @@ import type { Task as ApiTask, CreateTaskParams, TaskId } from "@/api/domain/tas import { Repository, type RepositoryReader } from "@/data/repository"; import { SubscriptionManager, type UnsubscribeCallback } from "@/data/subscriptions"; import type { Task } from "@/data/task"; +import type { Query } from "@/query/query"; import { Maybe } from "@/utils/maybe"; export enum QueryErrorKind { @@ -93,30 +95,41 @@ export class TodoistAdapter { }; } - public subscribe(query: string, callback: OnSubscriptionChange): [UnsubscribeCallback, Refresh] { + public subscribe(query: Query, callback: OnSubscriptionChange): [UnsubscribeCallback, Refresh] { const fetcher = this.buildQueryFetcher(query); const subscription = new Subscription(callback, fetcher, () => true); return [this.subscriptions.subscribe(subscription), subscription.update]; } - private buildQueryFetcher(query: string): SubscriptionFetcher { + private buildQueryFetcher(query: Query): SubscriptionFetcher { return async () => { if (!this.api.hasValue()) { return undefined; } - const data = await this.api.withInner((api) => api.getTasks(query)); - const hydrated = data.map((t) => this.hydrate(t)); - return hydrated; + + let result: Task[]; + if (query.viewCompleted) { + const params: GetCompletedTasksParams = { + limit: query.completedLimit, + since: query.completedSince, + until: query.completedUntil, + }; + + const data = await this.api.withInner((api) => api.getCompletedTasks(params)); + result = data.map((t) => this.hydrateCompletedTask(t)); + } else { + const data = await this.api.withInner((api) => api.getTasks(query.filter)); + result = data.map((t) => this.hydrate(t)); + } + + return result; }; } private hydrate(apiTask: ApiTask): Task { - const project = this.projects.byId(apiTask.projectId); - const section = apiTask.sectionId - ? (this.sections.byId(apiTask.sectionId) ?? makeUnknownSection(apiTask.sectionId)) - : undefined; - - const labels = apiTask.labels.map((id) => this.labels.byName(id) ?? makeUnknownLabel()); + const project = this.getProjectById(apiTask.projectId); + const section = this.getSectionById(apiTask.sectionId); + const labels = this.getLabelsByIds(apiTask.labels); return { id: apiTask.id, @@ -137,6 +150,37 @@ export class TodoistAdapter { }; } + private hydrateCompletedTask(apiCompletedTask: CompletedTask): Task { + const project = this.getProjectById(apiCompletedTask.project_id); + const section = this.getSectionById(apiCompletedTask.section_id); + const labels = this.getLabelsByIds(apiCompletedTask.item_object.labels); + + return { + id: apiCompletedTask.task_id, + createdAt: apiCompletedTask.completed_at, + content: apiCompletedTask.content, + description: apiCompletedTask.item_object.description, + priority: apiCompletedTask.item_object.priority, + due: apiCompletedTask.item_object.due ?? undefined, + + project: project ?? makeUnknownProject(apiCompletedTask.project_id), + section: section, + labels: labels, + }; + } + + private getProjectById(projectId: string): Project | undefined { + return this.projects.byId(projectId); + } + + private getSectionById(sectionId: string | null): Section | undefined { + return sectionId ? (this.sections.byId(sectionId) ?? makeUnknownSection(sectionId)) : undefined; + } + + private getLabelsByIds(labels: string[]): Label[] { + return labels.map((id) => this.labels.byName(id) ?? makeUnknownLabel()); + } + private async closeTask(id: TaskId): Promise { this.tasksPendingClose.push(id); diff --git a/plugin/src/data/task.ts b/plugin/src/data/task.ts index 16f4a1e..8eb397f 100644 --- a/plugin/src/data/task.ts +++ b/plugin/src/data/task.ts @@ -6,7 +6,7 @@ import type { Priority, TaskId } from "@/api/domain/task"; export type Task = { id: TaskId; - createdAt: string; + createdAt?: string; content: string; description: string; @@ -19,5 +19,5 @@ export type Task = { priority: Priority; due?: DueDate; - order: number; + order?: number; }; diff --git a/plugin/src/data/transformations/sorting.ts b/plugin/src/data/transformations/sorting.ts index ffbe534..8b1dbd7 100644 --- a/plugin/src/data/transformations/sorting.ts +++ b/plugin/src/data/transformations/sorting.ts @@ -33,7 +33,7 @@ function compareTask(self: T, other: T, sorting: SortingVariant) case SortingVariant.DateDescending: return -compareTaskDate(self, other); case SortingVariant.Order: - return self.order - other.order; + return (self.order ?? 0) - (other.order ?? 0); case SortingVariant.DateAdded: return compareTaskDateAdded(self, other); case SortingVariant.DateAddedDescending: @@ -89,8 +89,9 @@ function compareTaskDate(self: T, other: T): number { } function compareTaskDateAdded(self: T, other: T): number { - const selfDate = parseAbsoluteToLocal(self.createdAt); - const otherDate = parseAbsoluteToLocal(other.createdAt); + const now = new Date().toISOString(); + const selfDate = parseAbsoluteToLocal(self.createdAt ?? now); + const otherDate = parseAbsoluteToLocal(other.createdAt ?? now); return selfDate.compare(otherDate) < 0 ? -1 : 1; } diff --git a/plugin/src/query/parser.test.ts b/plugin/src/query/parser.test.ts index 2f94657..1acce43 100644 --- a/plugin/src/query/parser.test.ts +++ b/plugin/src/query/parser.test.ts @@ -107,6 +107,35 @@ describe("parseQuery - rejections", () => { show: "nonee", }, }, + { + description: "completedLimit must be between 1 and 200", + input: { + viewCompleted: true, + completedLimit: 201, + }, + }, + { + description: "completedSince must be valid datetime", + input: { + viewCompleted: true, + completedSince: "invalid-date", + }, + }, + { + description: "completedUntil must be after completedSince", + input: { + viewCompleted: true, + completedSince: "2024-03-01T00:00:00", + completedUntil: "2024-02-01T00:00:00", + }, + }, + { + description: "autorefresh must be at least 9 seconds when viewing completed tasks", + input: { + viewCompleted: true, + autorefresh: 5, + }, + }, ]; for (const tc of testcases) { @@ -133,6 +162,10 @@ function makeQuery(opts?: Partial): Query { ShowMetadataVariant.Labels, ]), groupBy: opts?.groupBy ?? GroupVariant.None, + viewCompleted: opts?.viewCompleted ?? false, + completedLimit: opts?.completedLimit, + completedSince: opts?.completedSince, + completedUntil: opts?.completedUntil, }; } @@ -219,6 +252,34 @@ describe("parseQuery", () => { show: new Set(), }), }, + { + description: "with viewCompleted", + input: { + filter: "bar", + viewCompleted: true, + }, + expectedOutput: makeQuery({ + filter: "bar", + viewCompleted: true, + }), + }, + { + description: "with completed tasks parameters", + input: { + filter: "bar", + viewCompleted: true, + completedLimit: 100, + completedSince: "2024-01-01T00:00:00", + completedUntil: "2024-03-31T23:59:59", + }, + expectedOutput: makeQuery({ + filter: "bar", + viewCompleted: true, + completedLimit: 100, + completedSince: new Date("2024-01-01T00:00:00"), + completedUntil: new Date("2024-03-31T23:59:59"), + }), + }, ]; for (const tc of testcases) { diff --git a/plugin/src/query/parser.ts b/plugin/src/query/parser.ts index 1749230..6fd15c5 100644 --- a/plugin/src/query/parser.ts +++ b/plugin/src/query/parser.ts @@ -4,6 +4,7 @@ import YAML from "yaml"; import { z } from "zod"; type ErrorTree = string | { msg: string; children: ErrorTree[] }; +const MIN_COMPLETED_TASKS_AUTOREFRESH = 9; export class ParsingError extends Error { messages: ErrorTree[]; @@ -114,11 +115,12 @@ const defaults = { ShowMetadataVariant.Project, ], groupBy: GroupVariant.None, + completedLimit: 30, }; -const querySchema = z.object({ +const baseQuerySchema = z.object({ name: z.string().optional().default(""), - filter: z.string(), + filter: z.string().optional(), autorefresh: z.number().nonnegative().optional().default(0), sorting: z .array(sortingSchema) @@ -129,9 +131,54 @@ const querySchema = z.object({ .optional() .transform((val) => val ?? defaults.show), groupBy: groupBySchema.optional().transform((val) => val ?? defaults.groupBy), + viewCompleted: z.boolean().optional().default(false), + completedLimit: z + .number() + .optional() + .refine((val) => !val || (val >= 1 && val <= 200), { + message: "Completed tasks limit must be between 1 and 200", + }), + completedSince: z + .string() + .datetime({ local: true }) + .transform((val) => (val ? new Date(val) : undefined)) + .optional(), + completedUntil: z + .string() + .datetime({ local: true }) + .transform((val) => (val ? new Date(val) : undefined)) + .optional(), }); -const validQueryKeys: string[] = querySchema.keyof().options; +const querySchema = baseQuerySchema + .refine((data) => data.viewCompleted || (data.filter && data.filter.trim() !== ""), { + message: "filter is required when viewCompleted is false", + path: ["filter"], + }) + .refine( + (data) => { + if (data.completedSince && data.completedUntil) { + return data.completedUntil >= data.completedSince; + } + return true; + }, + { + message: "completedUntil must be later than or equal to completedSince", + }, + ) + .refine( + (data) => { + if (data.viewCompleted && data.autorefresh > 0) { + return data.autorefresh >= MIN_COMPLETED_TASKS_AUTOREFRESH; // because of Todoist sync API limits + } + return true; + }, + { + message: "When viewing completed tasks, autorefresh must be at least 9 seconds", + }, + ); + +const validQueryKeys: string[] = baseQuerySchema.keyof().options; function parseObjectZod(query: Record): [Query, QueryWarning[]] { const warnings: QueryWarning[] = []; @@ -151,11 +198,15 @@ function parseObjectZod(query: Record): [Query, QueryWarning[]] return [ { name: out.data.name, - filter: out.data.filter, + filter: out.data.filter ?? "", autorefresh: out.data.autorefresh, sorting: out.data.sorting, show: new Set(out.data.show), groupBy: out.data.groupBy, + viewCompleted: out.data.viewCompleted, + completedLimit: out.data.completedLimit, + completedSince: out.data.completedSince, + completedUntil: out.data.completedUntil, }, warnings, ]; diff --git a/plugin/src/query/query.ts b/plugin/src/query/query.ts index 6605d4d..911034f 100644 --- a/plugin/src/query/query.ts +++ b/plugin/src/query/query.ts @@ -31,4 +31,8 @@ export type Query = { sorting: SortingVariant[]; show: Set; groupBy: GroupVariant; + viewCompleted: boolean; + completedLimit?: number; + completedSince?: Date; + completedUntil?: Date; }; diff --git a/plugin/src/query/replacements.test.ts b/plugin/src/query/replacements.test.ts index 6fe0d29..0172b18 100644 --- a/plugin/src/query/replacements.test.ts +++ b/plugin/src/query/replacements.test.ts @@ -61,6 +61,8 @@ describe("applyReplacements", () => { groupBy: GroupVariant.None, sorting: [], show: new Set(), + viewCompleted: false, + completedLimit: 1, }; applyReplacements(query, new FakeContext(tc.filePath ?? "")); diff --git a/plugin/src/ui/query/QueryRoot.tsx b/plugin/src/ui/query/QueryRoot.tsx index 2d63f1f..ac26ab6 100644 --- a/plugin/src/ui/query/QueryRoot.tsx +++ b/plugin/src/ui/query/QueryRoot.tsx @@ -23,7 +23,7 @@ const useSubscription = ( const [refreshedTimestamp, setRefreshedTimestamp] = useState(undefined); useEffect(() => { - const [unsub, refresh] = plugin.services.todoist.subscribe(query.filter, (results) => { + const [unsub, refresh] = plugin.services.todoist.subscribe(query, (results) => { callback(results); setRefreshedTimestamp(new Date()); }); @@ -152,6 +152,14 @@ const QueryResponseHandler: React.FC<{ ); } + if (query.viewCompleted) { + return ( + + + + ); + } + return ( diff --git a/plugin/src/ui/query/displays/CompletedTaskDisplay.tsx b/plugin/src/ui/query/displays/CompletedTaskDisplay.tsx new file mode 100644 index 0000000..1f4c96b --- /dev/null +++ b/plugin/src/ui/query/displays/CompletedTaskDisplay.tsx @@ -0,0 +1,28 @@ +import type { Task } from "@/data/task"; +import { CompletedTask } from "@/ui/query/task/CompletedTask"; +import { AnimatePresence, motion } from "framer-motion"; +import type React from "react"; + +type Props = { + tasks: Task[]; +}; + +export const CompletedTaskDisplay: React.FC = ({ tasks }) => { + return ( +
+ + {tasks.map((task, index) => ( + + + + ))} + +
+ ); +}; diff --git a/plugin/src/ui/query/displays/index.ts b/plugin/src/ui/query/displays/index.ts index ab013b5..2623f0c 100644 --- a/plugin/src/ui/query/displays/index.ts +++ b/plugin/src/ui/query/displays/index.ts @@ -1,3 +1,4 @@ +import { CompletedTaskDisplay } from "@/ui/query/displays/CompletedTaskDisplay"; import { EmptyDisplay } from "@/ui/query/displays/EmptyDisplay"; import { ErrorDisplay } from "@/ui/query/displays/ErrorDisplay"; import { GroupedDisplay } from "@/ui/query/displays/GroupedDisplay"; @@ -10,4 +11,5 @@ export const Displays = { List: ListDisplay, Grouped: GroupedDisplay, NotReady: NotReadyDisplay, + Completed: CompletedTaskDisplay, }; diff --git a/plugin/src/ui/query/task/CompletedTask.tsx b/plugin/src/ui/query/task/CompletedTask.tsx new file mode 100644 index 0000000..25656fe --- /dev/null +++ b/plugin/src/ui/query/task/CompletedTask.tsx @@ -0,0 +1,29 @@ +import type { Task } from "@/data/task"; +import { ShowMetadataVariant } from "@/query/query"; +import Markdown from "@/ui/components/markdown"; +import { QueryContext } from "@/ui/context"; +import type React from "react"; + +type Props = { + task: Task; +}; + +export const CompletedTask: React.FC = ({ task }) => { + const query = QueryContext.use(); + + const shouldRenderDescription = + query.show.has(ShowMetadataVariant.Description) && task.description !== ""; + + return ( +
+
+ + {shouldRenderDescription && ( +
+ +
+ )} +
+
+ ); +};