From 0566c8834b3765a915227673574b5ca478fe1f62 Mon Sep 17 00:00:00 2001 From: Mike Eling Date: Sat, 23 Dec 2023 20:38:28 +0100 Subject: [PATCH] :sparkles: Implement habits module --- example/index.ts | 71 ++++++++--- src/client.ts | 5 + src/modules/habits.ts | 80 ++++++++++++ src/modules/tasks.ts | 2 +- src/types.ts | 78 +++++++++++- tests/mocks.ts | 91 +++++++++++++- .../modules/__snapshots__/habits.test.ts.snap | 114 ++++++++++++++++++ .../modules/__snapshots__/tasks.test.ts.snap | 36 ++++++ tests/modules/habits.test.ts | 50 ++++++++ tests/modules/tasks.test.ts | 4 + 10 files changed, 511 insertions(+), 20 deletions(-) create mode 100644 src/modules/habits.ts create mode 100644 tests/modules/__snapshots__/habits.test.ts.snap create mode 100644 tests/modules/__snapshots__/tasks.test.ts.snap create mode 100644 tests/modules/habits.test.ts diff --git a/example/index.ts b/example/index.ts index 53388b9..8459c1d 100644 --- a/example/index.ts +++ b/example/index.ts @@ -1,4 +1,5 @@ import { ReclaimClient } from "../src"; +import { ReclaimHabitCreateMock, ReclaimHabitUpdateMock, ReclaimTaskCreateMock, ReclaimTaskUpdateMock } from "../tests/mocks"; // Create a new ReclaimClient instance. const client = new ReclaimClient(); @@ -6,20 +7,7 @@ const client = new ReclaimClient(); const createTaskExample = async () => { console.log("\n\nCreate a task =>\n"); - const newTask = await client.tasks.create({ - "title": "Funky Imitation Game", - "eventColor": null, - "eventCategory": "WORK", - "timeChunksRequired": 4, - "minChunkSize": 4, - "maxChunkSize": 8, - "alwaysPrivate": true, - "timeSchemeId": "989b3027-46c4-4729-bdec-1070fc4d8c0f", - "priority": "P2", - "snoozeUntil": null, - "due": "2023-12-17T16:00:00.000Z", - "onDeck": false - }); + const newTask = await client.tasks.create(ReclaimTaskCreateMock); return newTask; } @@ -35,9 +23,7 @@ const searchTasksExample = async () => { const updateTaskExample = async (taskId: number) => { console.log("\n\nUpdate a task =>\n"); - const updatedTask = await client.tasks.update(taskId, { - "title": "Indistinguishable Turing Test", - }); + const updatedTask = await client.tasks.update(taskId, ReclaimTaskUpdateMock); return updatedTask; } @@ -82,6 +68,46 @@ const markTaskAsDoneExample = async (taskId: number) => { return doneTask; } +const createHabitExample = async () => { + console.log("\n\nCreate a habit =>\n"); + + const newHabit = await client.habits.create(ReclaimHabitCreateMock); + + return newHabit; +} + +const updateHabitExample = async (habitId: number) => { + console.log("\n\nUpdate a habit =>\n"); + + const updatedHabit = await client.habits.update(habitId, ReclaimHabitUpdateMock); + + return updatedHabit; +} + +const searchHabitsExample = async () => { + console.log("\n\nSearch habits =>\n"); + + const habits = await client.habits.search({ title: ReclaimHabitCreateMock.title }); + + return habits; +} + +const getHabitExample = async (habitId: number) => { + console.log("\n\nGet a habit =>\n"); + + const habit = await client.habits.get(habitId); + + return habit; +} + +const deleteHabitExample = async (habitId: number) => { + console.log("\n\nDelete a habit =>\n"); + + const deletedHabit = await client.habits.delete(habitId); + + return deletedHabit; +} + // This is an example of how to use the ReclaimClient class. const main = async () => { console.clear(); @@ -97,6 +123,17 @@ const main = async () => { await getTaskExample(taskId); await markTaskAsDoneExample(taskId); await deleteTaskExample(taskId); + + const createdHabit = await createHabitExample(); + const habitId = createdHabit.id; + + await searchHabitsExample(); + + await updateHabitExample(habitId); + + await getHabitExample(habitId); + + await deleteHabitExample(habitId); } main(); \ No newline at end of file diff --git a/src/client.ts b/src/client.ts index c8b2c2b..4ede592 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,6 +1,7 @@ import { ReclaimTasks } from "./modules/tasks"; import { ReclaimUsers } from "./modules/users"; import { ReclaimCalendars } from "./modules/calendars"; +import { ReclaimHabits } from "./modules/habits"; import { config } from "./config"; /** @@ -37,6 +38,10 @@ export class ReclaimClient { return new ReclaimCalendars(this); } + get habits() { + return new ReclaimHabits(this); + } + /** * @description A generic fetcher for the Reclaim API. * @param endpoint diff --git a/src/modules/habits.ts b/src/modules/habits.ts new file mode 100644 index 0000000..1fd3520 --- /dev/null +++ b/src/modules/habits.ts @@ -0,0 +1,80 @@ +import { + ReclaimEndpoints, + ReclaimHabit, + ReclaimHabitCreate, +} from "../types"; +import { ReclaimClient } from "../client"; +import { filterArray } from "../utils"; + +/** + * A class for interacting with Reclaim habits. + */ +export class ReclaimHabits { + private path = ReclaimEndpoints.Habits; + private client: ReclaimClient; + + constructor(client: ReclaimClient) { + this.client = client; + } + + /** + * @description Create a new habit. + * @param habit The habit to create. + * @returns {ReclaimHabit} + */ + async create(habit: ReclaimHabitCreate): Promise { + const request = await this.client._fetcher(this.path, { + method: "POST", + body: JSON.stringify(habit), + }); + + const createdHabit = request.find((h: ReclaimHabit) => h.title === habit.title); + + return createdHabit; + } + + async search(filters?: Partial): Promise { + const response = (await this.client._fetcher(this.path, { + method: "GET", + })) as ReclaimHabit[]; + + if (!filters) return response; + + // Filter the response based on the filter object + return filterArray(response, filters); + } + + /** + * @description Get a single habit by ID. + * @param id The ID of the habit to get. + * @returns {ReclaimHabit | null} + */ + async get(id: number): Promise { + const habits = await this.search(); + + return habits.find((habit) => habit.id === id) || null; + } + + /** + * @description Update a habit. + * @param habit The habit to update. + * @returns {ReclaimHabit} + */ + async update(id: number, habit: Partial): Promise { + return await this.client._fetcher(`${this.path}/${id}`, { + method: "PATCH", + body: JSON.stringify(habit), + }); + } + + /** + * @description Delete a habit. + * @param id The ID of the habit to delete. + * @returns {null} + */ + async delete(id: number): Promise<{ taskOrHabit: ReclaimHabit, events: unknown[] }> { + return await this.client._fetcher(`planner/policy/habit/${id}`, { + method: "DELETE", + }); + } +} diff --git a/src/modules/tasks.ts b/src/modules/tasks.ts index 1141f73..e463365 100644 --- a/src/modules/tasks.ts +++ b/src/modules/tasks.ts @@ -62,7 +62,7 @@ export class ReclaimTasks { * @param task The task to update. * @returns {ReclaimTask} */ - async update(id: number, task: Partial): Promise { + async update(id: number, task: Partial): Promise { return await this.client._fetcher(`${this.path}/${id}`, { method: "PATCH", body: JSON.stringify(task), diff --git a/src/types.ts b/src/types.ts index 0d3c6dd..79e13f0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,6 @@ export enum ReclaimEndpoints { Tasks = "tasks", + Habits = "assist/habits/daily", Planner = "planner", Users = "users", Calendars = "calendars", @@ -63,6 +64,79 @@ export type ReclaimTaskCreate = Pick< | "onDeck" >; +/** + * An interface representing a Reclaim habit. + * Warning: This interface was reverse engineered from the Reclaim API and may be incomplete. + */ +export interface ReclaimHabit { + id: number; + title: string; + additionalDescription?: string | null; + alwaysPrivate: boolean; + eventCategory: string; + eventSubType: string; + eventColor?: string | "PERSONAL" | "WORK" | null; + created: Date; + updated: Date; + defenseAggression: string | "DEFAULT"; + recurringAssignmentType: string; + invitees: Array; + enabled: boolean; + durationMin: number; + durationMax: number; + idealDay?: string | null; + idealTime: string; + index: number; + elevated: boolean; + type: string; + reservedWords: Array; + notification: boolean; + timePolicyType: string; + oneOffPolicy: { + dayHours: Record; + startOfDay?: string; + endOfDay?: string; + }>; + startOfWeek?: string; + endOfWeek?: string; + }; + autoDecline: boolean; + adjusted: boolean; + snoozeUntil?: string | null; + recurrence?: any | null; + priority: string | "P0" | "P1" | "P2" | "P3" | "P4"; + timeSchemeId: string | null; + timesPerPeriod: number; +} + +export type ReclaimHabitCreate = Pick< + ReclaimHabit, + | "additionalDescription" + | "alwaysPrivate" + | "autoDecline" + | "defenseAggression" + | "durationMax" + | "durationMin" + | "enabled" + | "eventCategory" + | "eventColor" + | "idealDay" + | "idealTime" + | "index" + | "invitees" + | "notification" + | "oneOffPolicy" + | "priority" + | "recurrence" + | "reservedWords" + | "snoozeUntil" + | "timePolicyType" + | "timeSchemeId" + | "timesPerPeriod" + | "title" + >; + /** * An interface representing a Reclaim user. * Warning: This interface was reverse engineered from the Reclaim API and may be incomplete. @@ -518,4 +592,6 @@ export interface ReclaimUser { */ export interface ReclaimCalendar { id: number; -} \ No newline at end of file +} + +export type WeekDays = "SUNDAY" | "MONDAY" | "TUESDAY" | "WEDNESDAY" | "THURSDAY" | "FRIDAY" | "SATURDAY"; \ No newline at end of file diff --git a/tests/mocks.ts b/tests/mocks.ts index 926842a..bc54387 100644 --- a/tests/mocks.ts +++ b/tests/mocks.ts @@ -1,4 +1,4 @@ -import { ReclaimTaskCreate } from "../src/types"; +import { ReclaimHabitCreate, ReclaimTaskCreate } from "../src/types"; export const ReclaimTaskCreateMock: ReclaimTaskCreate = Object.freeze({ title: "Funky Imitation Game", @@ -17,4 +17,93 @@ export const ReclaimTaskCreateMock: ReclaimTaskCreate = Object.freeze({ export const ReclaimTaskUpdateMock: Partial = Object.freeze({ title: "Indistinguishable Turing Test", +}); + +export const ReclaimHabitCreateMock: ReclaimHabitCreate = Object.freeze({ + "title": "Positive Habit", + "eventCategory": "WORK", + "eventColor": null, + "enabled": true, + "defenseAggression": "DEFAULT", + "durationMin": 15, + "durationMax": 120, + "idealTime": "09:00:00", + "recurrence": null, + "alwaysPrivate": false, + "invitees": [], + "index": 0, + "timesPerPeriod": 0, + "idealDay": null, + "snoozeUntil": "2023-12-24T11:00:00.000Z", + "reservedWords": [], + "notification": true, + "autoDecline": false, + "timePolicyType": "ONE_OFF", + "timeSchemeId": null, + "oneOffPolicy": { + "dayHours": { + "MONDAY": { + "intervals": [ + { + "start": "09:00:00", + "end": "17:00:00" + } + ] + }, + "TUESDAY": { + "intervals": [ + { + "start": "09:00:00", + "end": "17:00:00" + } + ] + }, + "WEDNESDAY": { + "intervals": [ + { + "start": "09:00:00", + "end": "17:00:00" + } + ] + }, + "THURSDAY": { + "intervals": [ + { + "start": "09:00:00", + "end": "17:00:00" + } + ] + }, + "FRIDAY": { + "intervals": [ + { + "start": "09:00:00", + "end": "17:00:00" + } + ] + }, + "SATURDAY": { + "intervals": [ + { + "start": "09:00:00", + "end": "17:00:00" + } + ] + }, + "SUNDAY": { + "intervals": [ + { + "start": "09:00:00", + "end": "17:00:00" + } + ] + } + } + }, + "additionalDescription": "Hello!", + "priority": "P3" +}); + +export const ReclaimHabitUpdateMock: Partial = Object.freeze({ + title: "Negative Habit", }); \ No newline at end of file diff --git a/tests/modules/__snapshots__/habits.test.ts.snap b/tests/modules/__snapshots__/habits.test.ts.snap new file mode 100644 index 0000000..fc80c4b --- /dev/null +++ b/tests/modules/__snapshots__/habits.test.ts.snap @@ -0,0 +1,114 @@ +// Bun Snapshot v1, https://goo.gl/fbAQLP + +exports[`ReclaimHabits should run habits in sequence 1`] = ` +{ + "additionalDescription": "Hello!", + "adjusted": false, + "alwaysPrivate": false, + "autoDecline": false, + "created": undefined, + "defenseAggression": "DEFAULT", + "durationMax": 120, + "durationMin": 15, + "elevated": false, + "enabled": true, + "eventCategory": "WORK", + "eventSubType": "FOCUS", + "id": undefined, + "idealTime": "09:00:00", + "index": 18, + "invitees": [], + "notification": true, + "oneOffPolicy": { + "dayHours": { + "FRIDAY": { + "endOfDay": "17:00:00", + "intervals": [ + { + "duration": 28800, + "end": "17:00:00", + "start": "09:00:00", + }, + ], + "startOfDay": "09:00:00", + }, + "MONDAY": { + "endOfDay": "17:00:00", + "intervals": [ + { + "duration": 28800, + "end": "17:00:00", + "start": "09:00:00", + }, + ], + "startOfDay": "09:00:00", + }, + "SATURDAY": { + "endOfDay": "17:00:00", + "intervals": [ + { + "duration": 28800, + "end": "17:00:00", + "start": "09:00:00", + }, + ], + "startOfDay": "09:00:00", + }, + "SUNDAY": { + "endOfDay": "17:00:00", + "intervals": [ + { + "duration": 28800, + "end": "17:00:00", + "start": "09:00:00", + }, + ], + "startOfDay": "09:00:00", + }, + "THURSDAY": { + "endOfDay": "17:00:00", + "intervals": [ + { + "duration": 28800, + "end": "17:00:00", + "start": "09:00:00", + }, + ], + "startOfDay": "09:00:00", + }, + "TUESDAY": { + "endOfDay": "17:00:00", + "intervals": [ + { + "duration": 28800, + "end": "17:00:00", + "start": "09:00:00", + }, + ], + "startOfDay": "09:00:00", + }, + "WEDNESDAY": { + "endOfDay": "17:00:00", + "intervals": [ + { + "duration": 28800, + "end": "17:00:00", + "start": "09:00:00", + }, + ], + "startOfDay": "09:00:00", + }, + }, + "endOfWeek": "SUNDAY", + "startOfWeek": "MONDAY", + }, + "priority": "P3", + "recurringAssignmentType": "DAILY_HABIT", + "reservedWords": [], + "snoozeUntil": "2023-12-24T00:00:00+01:00", + "timePolicyType": "ONE_OFF", + "title": "Positive Habit", + "type": "CUSTOM_DAILY", + "updated": undefined, +} +`; diff --git a/tests/modules/__snapshots__/tasks.test.ts.snap b/tests/modules/__snapshots__/tasks.test.ts.snap new file mode 100644 index 0000000..082dd89 --- /dev/null +++ b/tests/modules/__snapshots__/tasks.test.ts.snap @@ -0,0 +1,36 @@ +// Bun Snapshot v1, https://goo.gl/fbAQLP + +exports[`ReclaimTasks should run tasks in sequence 1`] = ` +{ + "adjusted": false, + "alwaysPrivate": true, + "atRisk": false, + "created": undefined, + "deleted": false, + "due": "2029-11-21T17:30:00+01:00", + "eventCategory": "WORK", + "eventSubType": "FOCUS", + "id": undefined, + "index": 5023.775390625, + "maxChunkSize": 8, + "minChunkSize": 4, + "notes": "", + "onDeck": false, + "priority": "P2", + "readOnlyFields": [], + "recurringAssignmentType": "TASK", + "snoozeUntil": "2029-11-17T07:00:00+01:00", + "sortKey": 27790007, + "status": "NEW", + "taskSource": { + "type": "RECLAIM_APP", + }, + "timeChunksRemaining": 8, + "timeChunksRequired": 8, + "timeChunksSpent": 0, + "timeSchemeId": "989b3027-46c4-4729-bdec-1070fc4d8c0f", + "title": "Funky Imitation Game", + "type": "TASK", + "updated": undefined, +} +`; diff --git a/tests/modules/habits.test.ts b/tests/modules/habits.test.ts new file mode 100644 index 0000000..bae054f --- /dev/null +++ b/tests/modules/habits.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { ReclaimClient } from "../../src"; +import { ReclaimHabits } from "../../src/modules/habits"; +import { ReclaimHabitCreateMock, ReclaimHabitUpdateMock } from "../mocks"; + +describe("ReclaimHabits", () => { + let habits: ReclaimHabits; + + beforeEach(() => { + const client = new ReclaimClient(); + habits = client.habits; + }); + + test("should be created", () => { + expect(habits).toBeTruthy(); + }); + + test("should run habits in sequence", async () => { + // Test that habits is created + expect(habits).toBeTruthy(); + + // Test habit creation + const createResults = await habits.create(ReclaimHabitCreateMock); + expect(createResults).toBeTruthy(); + expect(createResults.id).toBeGreaterThan(0); + + // Create a snapshot of the createResults object + const createSnapshot = { ...createResults, id: undefined, created: undefined, updated: undefined }; + expect(createSnapshot).toMatchSnapshot(); + + // Test habit search + const searchResults = await habits.search(); + expect(searchResults).toBeTruthy(); + expect(searchResults.length).toBeGreaterThan(0); + expect(searchResults[0].id).toBeGreaterThan(0); + + // Test habit update + const updateResults = await habits.update(createResults.id, ReclaimHabitUpdateMock); + expect(updateResults).toBeTruthy(); + + // Test habit get + const getResults = await habits.get(createResults.id); + expect(getResults).toBeTruthy(); + expect(getResults?.id).toBe(createResults.id); + + // Test habit delete + const deleteResults = await habits.delete(createResults.id); + expect(deleteResults.taskOrHabit.id).toBe(createResults.id); + }); +}); diff --git a/tests/modules/tasks.test.ts b/tests/modules/tasks.test.ts index e874f13..060bab8 100644 --- a/tests/modules/tasks.test.ts +++ b/tests/modules/tasks.test.ts @@ -23,6 +23,10 @@ describe("ReclaimTasks", () => { const createResults = await tasks.create(ReclaimTaskCreateMock); expect(createResults).toBeTruthy(); expect(createResults.id).toBeGreaterThan(0); + + // Create a snapshot of the createResults object + const createSnapshot = { ...createResults, id: undefined, created: undefined, updated: undefined }; + expect(createSnapshot).toMatchSnapshot(); // Test task search const searchResults = await tasks.search();