Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/TodoistApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
Comment,
Task,
CurrentUser,
ProductivityStats,
} from './types/entities'
import {
AddCommentArgs,
Expand Down Expand Up @@ -67,6 +68,7 @@ import {
PROJECT_UNARCHIVE,
ENDPOINT_REST_PROJECTS_ARCHIVED,
ENDPOINT_REST_USER,
ENDPOINT_REST_PRODUCTIVITY,
} from './consts/endpoints'
import {
validateComment,
Expand All @@ -81,6 +83,7 @@ import {
validateTask,
validateTaskArray,
validateUserArray,
validateProductivityStats,
} from './utils/validators'
import { z } from 'zod'

Expand Down Expand Up @@ -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<ProductivityStats> {
const response = await request<ProductivityStats>(
'GET',
this.syncApiBase,
ENDPOINT_REST_PRODUCTIVITY,
this.authToken,
)
return validateProductivityStats(response.data)
}
}
80 changes: 78 additions & 2 deletions src/TodoistApi.user.test.ts
Original file line number Diff line number Diff line change
@@ -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') {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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)
})
})
})
1 change: 1 addition & 0 deletions src/consts/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
72 changes: 72 additions & 0 deletions src/types/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,78 @@ export const CurrentUserSchema = z.object({
*/
export type CurrentUser = z.infer<typeof CurrentUserSchema>

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(
ItemsWithDateSchema.extend({
date: z.string(),
}),
),
goals: z.object({
currentDailyStreak: StreakSchema,
currentWeeklyStreak: StreakSchema,
dailyGoal: z.number(),
ignoreDays: z.array(z.number()),
karmaDisabled: z.number(),
lastDailyStreak: StreakSchema,
lastWeeklyStreak: StreakSchema,
maxDailyStreak: StreakSchema,
maxWeeklyStreak: StreakSchema,
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(KarmaUpdateSchema),
projectColors: z.record(z.string(), z.string()),
weekItems: z.array(
ItemsWithDateSchema.extend({
from: z.string(),
to: z.string(),
}),
),
})

/**
* 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<typeof ProductivityStatsSchema>

export const ColorSchema = z.object({
/** @deprecated No longer used */
id: z.number(),
Expand Down
6 changes: 6 additions & 0 deletions src/utils/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}