From 9ef313c621b4a61e6c812c6e867df28bd0c7f9c0 Mon Sep 17 00:00:00 2001 From: Deepak Mardi Date: Wed, 10 Sep 2025 21:48:39 +0530 Subject: [PATCH 1/2] Add getProductivityStats Method to retrive productivity stats --- src/TodoistApi.ts | 17 +++++++ src/TodoistApi.user.test.ts | 80 ++++++++++++++++++++++++++++++- src/consts/endpoints.ts | 1 + src/types/entities.ts | 95 +++++++++++++++++++++++++++++++++++++ src/utils/validators.ts | 6 +++ 5 files changed, 197 insertions(+), 2 deletions(-) diff --git a/src/TodoistApi.ts b/src/TodoistApi.ts index 231e592..8662603 100644 --- a/src/TodoistApi.ts +++ b/src/TodoistApi.ts @@ -6,6 +6,7 @@ import { Comment, Task, CurrentUser, + ProductivityStats, } from './types/entities' import { AddCommentArgs, @@ -67,6 +68,7 @@ import { PROJECT_UNARCHIVE, ENDPOINT_REST_PROJECTS_ARCHIVED, ENDPOINT_REST_USER, + ENDPOINT_REST_PRODUCTIVITY, } from './consts/endpoints' import { validateComment, @@ -81,6 +83,7 @@ import { validateTask, validateTaskArray, validateUserArray, + validateProductivityStats, } from './utils/validators' import { z } from 'zod' @@ -1018,4 +1021,18 @@ export class TodoistApi { ) return isSuccess(response) } + /** + * Retrieves productivity stats for the authenticated user. + * + * @returns A promise that resolves to the productivity stats. + */ + async getProductivityStats(): Promise { + const response = await request( + 'GET', + this.syncApiBase, + ENDPOINT_REST_PRODUCTIVITY, + this.authToken, + ) + return validateProductivityStats(response.data) + } } diff --git a/src/TodoistApi.user.test.ts b/src/TodoistApi.user.test.ts index 4aa8681..0487a3a 100644 --- a/src/TodoistApi.user.test.ts +++ b/src/TodoistApi.user.test.ts @@ -1,6 +1,6 @@ -import { TodoistApi, type CurrentUser } from '.' +import { TodoistApi, type CurrentUser, type ProductivityStats } from '.' import { DEFAULT_AUTH_TOKEN } from './testUtils/testDefaults' -import { getSyncBaseUri, ENDPOINT_REST_USER } from './consts/endpoints' +import { getSyncBaseUri, ENDPOINT_REST_USER, ENDPOINT_REST_PRODUCTIVITY } from './consts/endpoints' import { setupRestClientMock } from './testUtils/mocks' function getTarget(baseUrl = 'https://api.todoist.com') { @@ -41,6 +41,60 @@ const DEFAULT_CURRENT_USER_RESPONSE: CurrentUser = { weekendStartDay: 6, } +const PRODUCTIVITY_STATS_RESPONSE: ProductivityStats = { + completedCount: 42, + daysItems: [ + { + date: '2025-01-01', + items: [], + totalCompleted: 0, + }, + { + date: '2025-01-02', + items: [{ completed: 2, id: 'dummy-id-1' }], + totalCompleted: 2, + }, + ], + goals: { + currentDailyStreak: { count: 3, end: '2025-01-02', start: '2025-01-01' }, + currentWeeklyStreak: { count: 1, end: '2025-01-02', start: '2025-01-01' }, + dailyGoal: 5, + ignoreDays: [5, 6], + karmaDisabled: 0, + lastDailyStreak: { count: 2, end: '2025-01-01', start: '2024-12-31' }, + lastWeeklyStreak: { count: 1, end: '2024-12-31', start: '2024-12-25' }, + maxDailyStreak: { count: 4, end: '2024-12-20', start: '2024-12-17' }, + maxWeeklyStreak: { count: 2, end: '2024-12-10', start: '2024-12-03' }, + user: 'dummy-user', + userId: 'dummy-user-id', + vacationMode: 1, + weeklyGoal: 10, + }, + karma: 1234, + karmaGraphData: [{ date: '2025-01-01', karmaAvg: 1000 }], + karmaLastUpdate: 5, + karmaTrend: 'down', + karmaUpdateReasons: [ + { + negativeKarma: 1, + negativeKarmaReasons: ['reason1'], + newKarma: 1234, + positiveKarma: 2, + positiveKarmaReasons: ['reasonA'], + time: '2025-01-02T12:00:00.000Z', + }, + ], + projectColors: { 'dummy-project-id': 'blue' }, + weekItems: [ + { + from: '2025-01-01', + items: [{ completed: 3, id: 'dummy-id-2' }], + to: '2025-01-07', + totalCompleted: 3, + }, + ], +} + describe('TodoistApi user endpoints', () => { describe('getUser', () => { test('calls get on restClient with expected parameters', async () => { @@ -122,4 +176,26 @@ describe('TodoistApi user endpoints', () => { expect(actual.tzInfo.timezone).toBe('Europe/Madrid') }) }) + + describe('getProductivityStats', () => { + test('calls get on expected url', async () => { + const requestMock = setupRestClientMock(PRODUCTIVITY_STATS_RESPONSE) + const api = getTarget() + await api.getProductivityStats() + expect(requestMock).toHaveBeenCalledTimes(1) + expect(requestMock).toHaveBeenCalledWith( + 'GET', + getSyncBaseUri(), + ENDPOINT_REST_PRODUCTIVITY, + DEFAULT_AUTH_TOKEN, + ) + }) + + test('returns result from rest client', async () => { + setupRestClientMock(PRODUCTIVITY_STATS_RESPONSE) + const api = getTarget() + const stats = await api.getProductivityStats() + expect(stats).toEqual(PRODUCTIVITY_STATS_RESPONSE) + }) + }) }) diff --git a/src/consts/endpoints.ts b/src/consts/endpoints.ts index 9d2f20a..9a1f5c4 100644 --- a/src/consts/endpoints.ts +++ b/src/consts/endpoints.ts @@ -35,6 +35,7 @@ export const ENDPOINT_REST_PROJECTS = 'projects' export const ENDPOINT_REST_PROJECTS_ARCHIVED = ENDPOINT_REST_PROJECTS + '/archived' export const ENDPOINT_REST_PROJECT_COLLABORATORS = 'collaborators' export const ENDPOINT_REST_USER = 'user' +export const ENDPOINT_REST_PRODUCTIVITY = ENDPOINT_REST_TASKS + '/completed/stats' export const PROJECT_ARCHIVE = 'archive' export const PROJECT_UNARCHIVE = 'unarchive' diff --git a/src/types/entities.ts b/src/types/entities.ts index 0a3aab8..0238b6d 100644 --- a/src/types/entities.ts +++ b/src/types/entities.ts @@ -308,6 +308,101 @@ export const CurrentUserSchema = z.object({ */ export type CurrentUser = z.infer +export const ProductivityStatsSchema = z.object({ + completedCount: z.number(), + daysItems: z.array( + z.object({ + date: z.string(), + items: z.array( + z.object({ + completed: z.number(), + id: z.string(), + }), + ), + totalCompleted: z.number(), + }), + ), + goals: z.object({ + currentDailyStreak: z.object({ + count: z.number(), + end: z.string(), + start: z.string(), + }), + currentWeeklyStreak: z.object({ + count: z.number(), + end: z.string(), + start: z.string(), + }), + dailyGoal: z.number(), + ignoreDays: z.array(z.number()), + karmaDisabled: z.number(), + lastDailyStreak: z.object({ + count: z.number(), + end: z.string(), + start: z.string(), + }), + lastWeeklyStreak: z.object({ + count: z.number(), + end: z.string(), + start: z.string(), + }), + maxDailyStreak: z.object({ + count: z.number(), + end: z.string(), + start: z.string(), + }), + maxWeeklyStreak: z.object({ + count: z.number(), + end: z.string(), + start: z.string(), + }), + user: z.string(), + userId: z.string(), + vacationMode: z.number(), + weeklyGoal: z.number(), + }), + karma: z.number(), + karmaGraphData: z.array( + z.object({ + date: z.string(), + karmaAvg: z.number(), + }), + ), + karmaLastUpdate: z.number(), + karmaTrend: z.string(), + karmaUpdateReasons: z.array( + z.object({ + negativeKarma: z.number(), + negativeKarmaReasons: z.array(z.any()), + newKarma: z.number(), + positiveKarma: z.number(), + positiveKarmaReasons: z.array(z.any()), + time: z.string(), + }), + ), + projectColors: z.record(z.string(), z.string()), + weekItems: z.array( + z.object({ + from: z.string(), + items: z.array( + z.object({ + completed: z.number(), + id: z.string(), + }), + ), + to: z.string(), + totalCompleted: z.number(), + }), + ), +}) + +/** + * Represents the Productivity stats for the authenticated user. + * @see https://developer.todoist.com/api/v1/#tag/User/operation/get_productivity_stats_api_v1_tasks_completed_stats_get + */ + +export type ProductivityStats = z.infer + export const ColorSchema = z.object({ /** @deprecated No longer used */ id: z.number(), diff --git a/src/utils/validators.ts b/src/utils/validators.ts index 211c157..7b31b0d 100644 --- a/src/utils/validators.ts +++ b/src/utils/validators.ts @@ -11,10 +11,12 @@ import { type Comment, type User, type CurrentUser, + type ProductivityStats, PersonalProjectSchema, WorkspaceProjectSchema, type WorkspaceProject, type PersonalProject, + ProductivityStatsSchema, } from '../types/entities' export function validateTask(input: unknown): Task { @@ -95,6 +97,10 @@ export function validateUserArray(input: unknown[]): User[] { return input.map(validateUser) } +export function validateProductivityStats(input: unknown): ProductivityStats { + return ProductivityStatsSchema.parse(input) +} + export function validateCurrentUser(input: unknown): CurrentUser { return CurrentUserSchema.parse(input) } From 24ccff28b95752893478c244e9b6324639069804 Mon Sep 17 00:00:00 2001 From: Deepak Mardi Date: Thu, 11 Sep 2025 00:05:01 +0530 Subject: [PATCH 2/2] refactor: extract reusable schemas in ProductivityStats --- src/types/entities.ts | 91 ++++++++++++++++--------------------------- 1 file changed, 34 insertions(+), 57 deletions(-) diff --git a/src/types/entities.ts b/src/types/entities.ts index 0238b6d..dc3a593 100644 --- a/src/types/entities.ts +++ b/src/types/entities.ts @@ -308,54 +308,48 @@ export const CurrentUserSchema = z.object({ */ export type CurrentUser = z.infer +const StreakSchema = z.object({ + count: z.number(), + start: z.string(), + end: z.string(), +}) + +const CompletedItemSchema = z.object({ + id: z.string(), + completed: z.number(), +}) + +const ItemsWithDateSchema = z.object({ + items: z.array(CompletedItemSchema), + totalCompleted: z.number(), +}) + +const KarmaUpdateSchema = z.object({ + time: z.string(), + newKarma: z.number(), + positiveKarma: z.number(), + negativeKarma: z.number(), + positiveKarmaReasons: z.array(z.any()), + negativeKarmaReasons: z.array(z.any()), +}) + export const ProductivityStatsSchema = z.object({ completedCount: z.number(), daysItems: z.array( - z.object({ + ItemsWithDateSchema.extend({ date: z.string(), - items: z.array( - z.object({ - completed: z.number(), - id: z.string(), - }), - ), - totalCompleted: z.number(), }), ), goals: z.object({ - currentDailyStreak: z.object({ - count: z.number(), - end: z.string(), - start: z.string(), - }), - currentWeeklyStreak: z.object({ - count: z.number(), - end: z.string(), - start: z.string(), - }), + currentDailyStreak: StreakSchema, + currentWeeklyStreak: StreakSchema, dailyGoal: z.number(), ignoreDays: z.array(z.number()), karmaDisabled: z.number(), - lastDailyStreak: z.object({ - count: z.number(), - end: z.string(), - start: z.string(), - }), - lastWeeklyStreak: z.object({ - count: z.number(), - end: z.string(), - start: z.string(), - }), - maxDailyStreak: z.object({ - count: z.number(), - end: z.string(), - start: z.string(), - }), - maxWeeklyStreak: z.object({ - count: z.number(), - end: z.string(), - start: z.string(), - }), + lastDailyStreak: StreakSchema, + lastWeeklyStreak: StreakSchema, + maxDailyStreak: StreakSchema, + maxWeeklyStreak: StreakSchema, user: z.string(), userId: z.string(), vacationMode: z.number(), @@ -370,28 +364,12 @@ export const ProductivityStatsSchema = z.object({ ), karmaLastUpdate: z.number(), karmaTrend: z.string(), - karmaUpdateReasons: z.array( - z.object({ - negativeKarma: z.number(), - negativeKarmaReasons: z.array(z.any()), - newKarma: z.number(), - positiveKarma: z.number(), - positiveKarmaReasons: z.array(z.any()), - time: z.string(), - }), - ), + karmaUpdateReasons: z.array(KarmaUpdateSchema), projectColors: z.record(z.string(), z.string()), weekItems: z.array( - z.object({ + ItemsWithDateSchema.extend({ from: z.string(), - items: z.array( - z.object({ - completed: z.number(), - id: z.string(), - }), - ), to: z.string(), - totalCompleted: z.number(), }), ), }) @@ -400,7 +378,6 @@ export const ProductivityStatsSchema = z.object({ * Represents the Productivity stats for the authenticated user. * @see https://developer.todoist.com/api/v1/#tag/User/operation/get_productivity_stats_api_v1_tasks_completed_stats_get */ - export type ProductivityStats = z.infer export const ColorSchema = z.object({