skip to content
Site header image Nerdy Momo Cat

Add to Habitify from Todoist w/ AI

Did you want a better input interface to your habit tracker rather than clicking multiple buttons?


Table of contents

I created a script to use AI to add logs and notes to habitify habits through todoist.

Why todoist? Because it has really nice project/section handling — and works really well with NLP for date parsing.

Why habitify? I bought habitify when it had a nice student discount running. It has a lifetime purchase option, has an API, is multi-platform and integrates with Google Fit (aka, checks all my major habit tracker checklist boxes)

See the complete code here. Something to note is that this relies on val.town specific imports, specifically blob and you’d need to modify that to host somewhere else.
import { blob } from "https://esm.town/v/std/blob";
import process from "node:process";
import { TodoistApi } from "npm:@doist/todoist-api-typescript";
import Instructor from "npm:@instructor-ai/instructor";
import Jimp from "npm:jimp";
import OpenAI from "npm:openai";
import { z } from "npm:zod";

const force_update_database = false; // set force update database to true
// if you added new items to habitify after running this script
// for the first time.

// INST: Add these keys in val env variables
// NOTE: the image notes don't work at the moment, and if you add a image note,
// the task is skipped

const TODOIST_API_KEY = process.env.TODOIST_API_KEY;
const HABITIFY_API_KEY = process.env.HABITIFY_API_KEY;
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
const DEF_TIMEZONE = "America/Los_Angeles"; // Get your timezone from here: https://stackoverflow.com/a/54500197

var add_to_habitify_todoist_project_id = "XXXX"; // INST: Project ID goes here
var todoist_dict_mapping = { // Note: You can choose to leave this dict empty and it will still work
  "Image Notes": { // This is for a section that is image notes only for any habit without adding a log
    "todoist-section-id": "XXXX", // INST: section id for just image notes section goes here
    "habitify-area-name": "undefined", // keep this value as undefined
    specialPrompt: "only_image", // keep the value as only_image
  },
  "Text Notes": { // This is for a section that is text notes only for any habit without adding a log
    "habitify-area-name": "undefined", // keep this value as undefined
    "todoist-section-id": "XXXX", // INST: section id for just image notes section goes here
    specialPrompt: "only_text", // keep the value as only_text
  },
  Cats: {
    "todoist-section-id": "XXXX", // section id for the area goes here
    "habitify-area-name": "Cats", // INST: habitify area name goes here. Match exactly
    specialPrompt: "", // keep the value as empty text
  },
  Food: { // You do not need to have a section that tracks calories, it is just a bonus
    "todoist-section-id": "XXXX", // section id for the area goes here
    "habitify-area-name": "Health", // INST: habitify area name goes here. Match exactly
    specialPrompt: "track_calories", // keep the value as track_calories if you want to
    // estimate calories based on entry. the estimation is pretty wild and can vary by +/- 500 kCal at least
  },
  // ADD any other sections as areas here to help the model choose amongst less habits.
  // Keep specialPrompt value as empty strng
};

const todoistapi = new TodoistApi(TODOIST_API_KEY);

const oai = new OpenAI({
  apiKey: OPENAI_API_KEY ?? undefined,
});

const client = Instructor({
  client: oai,
  mode: "TOOLS",
});

function getHabitifyAreaName(section_id) {
  if (!section_id) {
    return ["undefined", ""];
  }

  for (var key in todoist_dict_mapping) {
    if (todoist_dict_mapping[key]["todoist-section-id"] === section_id) {
      return [
        todoist_dict_mapping[key]["habitify-area-name"],
        todoist_dict_mapping[key]["specialPrompt"].toLowerCase(),
      ];
    }
  }

  return ["undefined", ""];
}

function convertDateObject(due) {
  function convertToISOWithOffset(datetimeStr, timezoneStr) {
    const date = new Date(datetimeStr);
    const [, sign, hours, minutes] = timezoneStr.match(
      /GMT ([+-])(\d{1,2}):(\d{2})/,
    );
    date.setUTCMinutes(
      date.getUTCMinutes()
        + (parseInt(hours) * 60 + parseInt(minutes)) * (sign === "+" ? 1 : -1),
    );
    return (
      date.toISOString().split(".")[0]
      + `${sign}${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`
    );
  }
  const formatDate = (date, datetime, timezone) => {
    let isoString = datetime ? datetime : date;
    if (timezone && timezone.startsWith("GMT") && timezone.length > 3) {
      return convertToISOWithOffset(datetime, timezone);
    } else {
      return isoString;
    }
  };

  const date_as_string = due
    ? formatDate(due.date, due.datetime, due.timezone)
    : new Date().toISOString();

  return date_as_string;
}
async function getCommentsForTask(taskId) {
  try {
    const comments = await todoistapi.getComments({ taskId });
    return comments;
  } catch (error) {
    console.error(`Error fetching comments for task ${taskId}:`, error);
    return [];
  }
}

async function resizeAndConvertImage(imageBuffer) {
  try {
    const image = await Jimp.read(imageBuffer);
    const resizedImage = await image.resize(800, 800, Jimp.RESIZE_BILINEAR);
    const jpegBuffer = await resizedImage.quality(80).getBufferAsync(Jimp.MIME_JPEG);

    if (jpegBuffer.length >= 2000000) {
      console.error("Error: Resized image size should be smaller than 2MB");
      return null;
    }

    return jpegBuffer;
  } catch (error) {
    console.error("Error processing image:", error);
    return null;
  }
}

async function downloadImage(url) {
  try {
    const response = await fetch(url, {
      headers: {
        Authorization: `Bearer ${TODOIST_API_KEY}`,
      },
    });
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }

    const imageBuffer = await response.buffer();
    const resizedImageBuffer = await resizeAndConvertImage(imageBuffer);

    return resizedImageBuffer;
  } catch (error) {
    console.error("Error downloading or processing image:", error);
    return null;
  }
}

async function createZodSchema(habitKeys, specialPrompt) {
  const transformDate = (date) => {
    if (!date.includes("T")) {
      date = date + "T23:00:00"; // if no time is included, consider it completed at 11pm
    }
    const parsedDate = Date.parse(date);
    const dateObj = isNaN(parsedDate) ? new Date() : new Date(parsedDate);

    const pad = (num) => String(num).padStart(2, "0");

    const year = dateObj.getFullYear();
    const month = pad(dateObj.getMonth() + 1);
    const day = pad(dateObj.getDate());
    const hours = pad(dateObj.getHours());
    const minutes = pad(dateObj.getMinutes());
    const seconds = pad(dateObj.getSeconds());
    // console.log(date);

    let offsetSign, offsetHours, offsetMinutes;
    const [datePart, timePart] = date.split("T");

    if (
      date.endsWith("Z")
      || !timePart
      || (timePart && !timePart.includes("+") && !timePart.includes("-"))
    ) {
      // Create a DateTimeFormat object for Pacific Time
      const pacificTime = new Intl.DateTimeFormat("en-US", {
        timeZone: DEF_TIMEZONE,
        hour: "2-digit",
        minute: "2-digit",
        second: "2-digit",
        hour12: false,
      });

      // Format the date to get the correct Pacific Time offset
      const pacificParts = pacificTime.formatToParts(dateObj);
      const pacificHours = parseInt(
        pacificParts.find((p) => p.type === "hour").value,
        10,
      );
      const pacificMinutes = parseInt(
        pacificParts.find((p) => p.type === "minute").value,
        10,
      );

      // Calculate the offset in minutes between the local time and UTC
      let offset = (pacificHours - dateObj.getUTCHours()) * 60
        + (pacificMinutes - dateObj.getUTCMinutes());

      // Adjust for day boundary crossing
      if (offset > 720) {
        offset -= 1440; // Subtract a full day in minutes
      } else if (offset < -720) {
        offset += 1440; // Add a full day in minutes
      }
      const absOffset = Math.abs(offset);
      offsetHours = pad(Math.floor(absOffset / 60));
      offsetMinutes = pad(absOffset % 60);
      offsetSign = offset <= 0 ? "-" : "+";
    } else {
      const offset = dateObj.getTimezoneOffset();
      const absOffset = Math.abs(offset);
      offsetHours = pad(Math.floor(absOffset / 60));
      offsetMinutes = pad(absOffset % 60);
      offsetSign = offset <= 0 ? "+" : "-";
    }

    return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}${offsetSign}${offsetHours}:${offsetMinutes}`;
  };

  let habitSchema;

  if (specialPrompt === "only_text" || specialPrompt === "only_image") {
    habitSchema = z.object({
      name: z.enum(habitKeys),
      note: z.string().default(""),
      date: z.string().transform(transformDate),
    });
  } else if (
    specialPrompt
    && (specialPrompt.includes("calorie") || specialPrompt.includes("food intake"))
  ) {
    habitSchema = z.object({
      unit: z.string().default("kCal"),
      name: z.enum(habitKeys),
      value: z.number().default(1),
      note: z.string().default(""),
      date: z.string().transform(transformDate),
    });
  } else {
    habitSchema = z.object({
      unit: z.string().default("rep"),
      name: z.enum(habitKeys),
      value: z.number().default(1),
      note: z.string().default(""),
      date: z.string().transform(transformDate),
    });
  }

  // Define the schema for the list of habits
  const habitsListSchema = z.array(habitSchema);

  return z.object({ habits: habitsListSchema });
}
function createPrompt(habitKeys_str, specialPrompt) {
  let prompt =
    "You are processing content into a list of habit objects. Based on the options of habits, infer appropriate values for the fields:\n";
  prompt += "The habits you can choose from are: " + habitKeys_str + ".\n";

  if (specialPrompt === "only_image" || specialPrompt === "only_text") {
    prompt +=
      "You are extracting the name of the habit, the date of the habit (if mentioned, or otherwise an empty string), and any note if it exists about a habit (if mentioned, or otherwise an empty string). Remember, a note is associated with only 1 habit and will say 'note that' or something like that.\n";
  } else if (
    specialPrompt
    && (specialPrompt.includes("calorie") || specialPrompt.includes("food intake"))
  ) {
    prompt +=
      "You are tracking habits related to calorie or food intake. You are extracting the name of the habit which will be something like 'track calories' or 'track food intake'. As the next step, estimate the calories for the food mentioned in the text as and use that as the value, the unit will be kCal, the date of the habit (if mentioned, or otherwise an empty string), and the food mentioned in the text will be the note for the habit along with the estimated calories in paranthesis.\n";
  } else {
    prompt +=
      "You are choosing the unit (like min or kg), the default is rep. You are choosing the value of the habit, the default is 1. You are getting the date of the habit, if mentioned, or otherwise an empty string. You are extracting any note if it exists about a habit, if mentioned, or otherwise an empty string. Remember, a note is associated with only 1 habit and will say 'note that' or something like that.\n";
  }

  prompt += "Infer and in step 1 ";
  prompt = prompt
    + (specialPrompt
        && (specialPrompt.includes("calorie") || specialPrompt.includes("food intake"))
      ? "a list with a SINGLE habit object that matches the calorie or food intake option from valid options"
      : "create a list of all the habits extracted from text that match the options")
    + "; and then as step 2 assign values to each of these habits' properties based on the provided content and the aforementioned context.";
  return prompt;
}
async function process_text(habits_list, text, specialPrompt) {
  // console.log(habits_list);
  const habitKeys = Object.keys(habits_list);
  const zod_schema = await createZodSchema(habitKeys, specialPrompt);

  const sys_prompt = createPrompt(habitKeys.join(), specialPrompt);
  const processed_message = await client.chat.completions.create({
    messages: [
      { role: "system", content: sys_prompt },
      { role: "user", content: text },
    ],
    model: "gpt-4o",
    response_model: {
      schema: zod_schema,
      name: "habit and value extraction",
    },
    max_retries: 3,
  });

  return processed_message;
}

async function addLog(habit_id, unit_type, value, target_date) {
  const url = `https://api.habitify.me/logs/${habit_id}`;
  const headers = {
    Authorization: HABITIFY_API_KEY,
    "Content-Type": "application/json",
  };
  const body = {
    unit_type: unit_type,
    value: value,
    target_date: target_date,
  };

  try {
    const response = await fetch(url, {
      method: "POST",
      headers: headers,
      body: JSON.stringify(body),
    });
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    const jsonResponse = await response.json();
    return jsonResponse;
  } catch (error) {
    console.error("Error:", error);
    process.exit(1);
  }
}
async function addTextNote(habit_id, created, content) {
  const url = `https://api.habitify.me/notes/${habit_id}`;
  const headers = {
    Authorization: HABITIFY_API_KEY,
    "Content-Type": "application/json",
  };
  const body = {
    created: created,
    content: content,
  };

  try {
    const response = await fetch(url, {
      method: "POST",
      headers: headers,
      body: JSON.stringify(body),
    });
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    const jsonResponse = await response.json();
    return jsonResponse;
  } catch (error) {
    console.error("Error:", error);
    process.exit(1);
  }
}

async function addImageNote(habit_id, created_at, imageBuffer) {
  const url = `https://api.habitify.me/notes/addImageNote/${habit_id}?created_at=${encodeURIComponent(created_at)}`;
  const headers = {
    Authorization: HABITIFY_API_KEY,
    "Content-Type": "image/jpeg",
  };

  try {
    const response = await fetch(url, {
      method: "POST",
      headers: headers,
      body: imageBuffer,
    });
    if (!response.ok) {
      console.log(response);
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    const jsonResponse = await response.json();
    return jsonResponse;
  } catch (error) {
    console.error("Error:", error);
    process.exit(1);
  }
}

async function get_habitify_database()
{
  const habits_list = await blob.getJSON("habitify_database");
  if (!habits_list || force_update_database)
  {
    const HABITIFY_API_KEY = process.env.HABITIFY_API_KEY;
    const url = "https://api.habitify.me/habits";
    const headers = {
      Authorization: HABITIFY_API_KEY,
      "Content-Type": "application/json",
    };

    try {
      const response = await fetch(url, { method: "GET", headers: headers });
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      const jsonResponse = await response.json();

      // Filter habits
      const filteredHabits = jsonResponse.data.filter(
        (habit) => !habit.is_archived && habit.log_method === "manual",
      );

      // Group habits by area
      const habitsByArea = {};
      filteredHabits.forEach((habit) => {
        const area = habit.area && Object.keys(habit.area).length > 0
          ? habit.area.name
          : "undefined";
        if (!habitsByArea[area]) {
          habitsByArea[area] = {};
        }
        habitsByArea[area][habit.name] = {
          id: habit.id,
          name: habit.name,
          unit_type: habit.goal.unit_type,
        };
        if (area !== "undefined") {
          if (!habitsByArea["undefined"]) {
            habitsByArea["undefined"] = {};
          }
          habitsByArea["undefined"][habit.name] = {
            id: habit.id,
            name: habit.name,
            unit_type: habit.goal.unit_type,
          };
        }
      });
      // Log the result JSON
      console.log(JSON.stringify(habitsByArea, null, 2));
      await blob.setJSON("habitify_database", habitsByArea);
      return habitsByArea;
    } catch (error) {
      console.error("Error:", error);
      process.exit(1);
    }
  }
  else
  {
    return habits_list;
  }
}

export default async function(interval: Interval) {
  const habits_list = await get_habitify_database();
  var tasks = await todoistapi.getTasks({
    projectId: add_to_habitify_todoist_project_id,
  });

  for (const task of tasks) {
    console.log(task);
    task.imageComments = null;
    if (task.commentCount > 0) {
      const imageComments = [];
      const todoist_comments = await getCommentsForTask(task.id);
      for (const comment of todoist_comments) {
        // console.log(comment);
        if (comment.attachment && comment.attachment.resourceType === "image") {
          const imageBuffer = await downloadImage(comment.attachment.image);
          if (imageBuffer) {
            imageComments.push(imageBuffer);
          }
        }
      }
      task.imageComments = imageComments ? imageComments : null;
    }

    /// START---REMOVE ONCE UPLOAD IS FIXED
    if (task.imageComments) {
      continue;
    }
    /// END---REMOVE ONCE UPLOAD IS FIXED

    const [habitifyAreaName, specialPrompt] = getHabitifyAreaName(
      task.sectionId,
    );
    const processed_task = await process_text(
      habits_list[habitifyAreaName],
      task.content
        + " dated "
        + convertDateObject(task.due)
        + "\n"
        + task.description,
      specialPrompt,
    );
    const habits = processed_task.habits;
    console.log(habits);
    for (const habit of habits) {
      if (specialPrompt != "only_image" || specialPrompt != "only_text") {
        await addLog(
          habits_list[habitifyAreaName][habit.name].id,
          habit.unit,
          habit.value,
          habit.date,
        );
      }
      if (habit.note) {
        await addTextNote(habits_list[habitifyAreaName][habit.name].id, habit.date, habit.note);
      }
      if (task.imageComments) {
        for (const imageComment of task.imageComments) {
          await addImageNote(
            habits_list[habitifyAreaName][habit.name].id,
            habit.date,
            imageComment,
          );
        }
      }
    }
    await todoistapi.deleteTask(task.id);
  }
}

Note: The image notes option doesn’t work at the moment because the habitify API documentation isn’t clear about the kind of object type it expects and no matter what I tried it refused to work.


Unlike the other times I have done these scripts, let’s do a walkthrough of this script, in case you want to write one of your own. I’ll walk you through my thought process, my desired coverage, the issues I ran into, and the resultant code.

Imports and constants

import { blob } from "https://esm.town/v/std/blob";
import process from "node:process";
import { TodoistApi } from "npm:@doist/todoist-api-typescript";
import Instructor from "npm:@instructor-ai/instructor";
import Jimp from "npm:jimp";
import OpenAI from "npm:openai";
import { z } from "npm:zod";

const force_update_database = false; // set force update database to true
// if you added new items to habitify after running this script
// for the first time.

const TODOIST_API_KEY = process.env.TODOIST_API_KEY;
const HABITIFY_API_KEY = process.env.HABITIFY_API_KEY;
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
const DEF_TIMEZONE = "America/Los_Angeles"; // Get your timezone from here: https://stackoverflow.com/a/54500197

This script integrates multiple APIs and libraries to manage tasks and data across different platforms. Here's a breakdown of its components and functionalities:

  1. Imports and Dependencies:
    • The script begins by importing various modules:
      • blob from an ESM source to handle binary data.
      • process from Node.js to access environment variables.
      • TodoistApi from the Todoist API package to interact with Todoist tasks.
      • Instructor from instructor package, used for easier function calling with OpenAI APIs
      • Jimp for image processing.
      • OpenAI to utilize AI models provided by OpenAI.
      • z from Zod, a validation library to ensure data integrity.
  2. Configuration Variables:
    • force_update_database: A boolean flag set to false by default. If set to true, it indicates that the database should be forcefully updated, useful when new items are added to Habitify.
    • API keys for Todoist, Habitify, and OpenAI are fetched from environment variables, ensuring sensitive data is not hard-coded into the script.
    • DEF_TIMEZONE is set to "America/Los_Angeles", which is used to handle timezone-specific operations. This can be modified by the user themselves to match their timezones.
      This is required because Todoist doesn’t provide the user timezone and a significant part of code is to try mapping two timezone values to acceptable parameters.

Define todoist information

var add_to_habitify_todoist_project_id = "XXXX"; // INST: Project ID goes here
var todoist_dict_mapping = { // Note: You can choose to leave this dict empty and it will still work
  "Image Notes": { // This is for a section that is image notes only for any habit without adding a log
    "todoist-section-id": "XXXX", // INST: section id for just image notes section goes here
    "habitify-area-name": "undefined", // keep this value as undefined
    specialPrompt: "only_image", // keep the value as only_image
  },
  "Text Notes": { // This is for a section that is text notes only for any habit without adding a log
    "habitify-area-name": "undefined", // keep this value as undefined
    "todoist-section-id": "XXXX", // INST: section id for just image notes section goes here
    specialPrompt: "only_text", // keep the value as only_text
  },
  Cats: {
    "todoist-section-id": "XXXX", // section id for the area goes here
    "habitify-area-name": "Cats", // INST: habitify area name goes here. Match exactly
    specialPrompt: "", // keep the value as empty text
  },
  Food: { // You do not need to have a section that tracks calories, it is just a bonus
    "todoist-section-id": "XXXX", // section id for the area goes here
    "habitify-area-name": "Health", // INST: habitify area name goes here. Match exactly
    specialPrompt: "track_calories", // keep the value as track_calories if you want to
    // estimate calories based on entry. the estimation is pretty wild and can vary by +/- 500 kCal at least
  },
  // ADD any other sections as areas here to help the model choose amongst less habits.
  // Keep specialPrompt value as empty strng
};
  1. Project and Section IDs:
    • add_to_habitify_todoist_project_id: This variable holds the Todoist project ID where tasks related to Habitify habits will be added. The placeholder "XXXX" should be replaced with the actual project ID.
  2. Dictionary for Mapping:
    • todoist_dict_mapping: This dictionary maps specific Habitify areas to Todoist sections. Each key represents a different category or type of task, such as "Image Notes" or "Text Notes".
      • Each category contains:
        • todoist-section-id: The ID of the Todoist section corresponding to the Habitify area. This needs to be replaced with actual section IDs.
        • habitify-area-name: The name of the area in Habitify. For some categories, this is intentionally set to "undefined" to indicate that the category does not correspond to a specific area in Habitify. Otherwise this should match exactly to the area name in habitify.
        • specialPrompt: A string used to trigger specific behaviors or features. For example, "only_image" and "only_text" are used to specify sections that handle only images or text, respectively.
  3. Special Cases:
    • specialPrompt like “track_calories" , “only_text” and “only_image” are more specific:
      • "only_image" and "only_text" are used to specify sections that handle only images or text notes, respectively, and do not add any logs to any of the habits.
      • For "Food", there is an additional feature to track calories, indicated by specialPrompt set to “track_calories". This feature provides a rough calorie estimate for entries.
  4. Flexibility and Customization:
    • The script allows for the addition of more sections by adding them to the todoist_dict_mapping dictionary. The specialPrompt can be left as an empty string if no special behavior is required for those sections.

Instantiate clients

const todoistapi = new TodoistApi(TODOIST_API_KEY);

const oai = new OpenAI({
  apiKey: OPENAI_API_KEY ?? undefined,
});

const client = Instructor({
  client: oai,
  mode: "TOOLS",
});

This code snippet initializes clients for interacting with the Todoist API, OpenAI, and an AI-driven Instructor tool. Here's a breakdown of each line:

  1. Todoist Client Initialization:
    • const todoistapi = new TodoistApi(TODOIST_API_KEY);
      • This line creates a new instance of the TodoistApi class using the TODOIST_API_KEY. This client is used to interact with Todoist's API for task and project management.
  2. OpenAI Client Initialization:
    • const oai = new OpenAI({ apiKey: OPENAI_API_KEY ?? undefined });
      • This initializes a new instance of the OpenAI client with the apiKey set to OPENAI_API_KEY. If OPENAI_API_KEY is not available, it defaults to undefined. This client enables the use of OpenAI's capabilities, such as GPT models for natural language processing.
  3. Instructor Client Initialization:
    • const client = Instructor({ client: oai, mode: "TOOLS" });
      • This line creates a new instance of the Instructor configured with the OpenAI client (oai). The mode is set to "TOOLS", specifying the operational mode of the Instructor instance, is function calling.

Helper functions

function getHabitifyAreaName(section_id) {
  if (!section_id) {
    return ["undefined", ""];
  }

  for (var key in todoist_dict_mapping) {
    if (todoist_dict_mapping[key]["todoist-section-id"] === section_id) {
      return [
        todoist_dict_mapping[key]["habitify-area-name"],
        todoist_dict_mapping[key]["specialPrompt"].toLowerCase(),
      ];
    }
  }

  return ["undefined", ""];
}

function convertDateObject(due) {
  function convertToISOWithOffset(datetimeStr, timezoneStr) {
    const date = new Date(datetimeStr);
    const [, sign, hours, minutes] = timezoneStr.match(
      /GMT ([+-])(\d{1,2}):(\d{2})/,
    );
    date.setUTCMinutes(
      date.getUTCMinutes()
        + (parseInt(hours) * 60 + parseInt(minutes)) * (sign === "+" ? 1 : -1),
    );
    return (
      date.toISOString().split(".")[0]
      + `${sign}${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`
    );
  }
  const formatDate = (date, datetime, timezone) => {
    let isoString = datetime ? datetime : date;
    if (timezone && timezone.startsWith("GMT") && timezone.length > 3) {
      return convertToISOWithOffset(datetime, timezone);
    } else {
      return isoString;
    }
  };

  const date_as_string = due
    ? formatDate(due.date, due.datetime, due.timezone)
    : new Date().toISOString();

  return date_as_string;
}

async function resizeAndConvertImage(imageBuffer) {
  try {
    const image = await Jimp.read(imageBuffer);
    const resizedImage = await image.resize(800, 800, Jimp.RESIZE_BILINEAR);
    const jpegBuffer = await resizedImage.quality(80).getBufferAsync(Jimp.MIME_JPEG);

    if (jpegBuffer.length >= 2000000) {
      console.error("Error: Resized image size should be smaller than 2MB");
      return null;
    }

    return jpegBuffer;
  } catch (error) {
    console.error("Error processing image:", error);
    return null;
  }
}

async function downloadImage(url) {
  try {
    const response = await fetch(url, {
      headers: {
        Authorization: `Bearer ${TODOIST_API_KEY}`,
      },
    });
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }

    const imageBuffer = await response.buffer();
    const resizedImageBuffer = await resizeAndConvertImage(imageBuffer);

    return resizedImageBuffer;
  } catch (error) {
    console.error("Error downloading or processing image:", error);
    return null;
  }
}

This code snippet defines several helper functions to facilitate operations related to task management and image processing:

  1. getHabitifyAreaName Function:
    • This function retrieves the Habitify area name and a special prompt associated with a given Todoist section ID.
    • It checks if the section_id is provided; if not, it returns ["undefined", ""].
    • It iterates over the todoist_dict_mapping dictionary to find a matching section ID and returns the corresponding Habitify area name and a lowercase version of the special prompt.
  2. convertDateObject Function:
    • Converts a date object into an ISO string with an optional timezone offset.
    • It defines a nested function convertToISOWithOffset to adjust the date object based on the provided timezone string.
    • The main function formats the date based on whether a specific datetime or just a date is provided, applying the timezone adjustment if necessary.
    • If no date is provided, it defaults to the current date in ISO format.
  3. resizeAndConvertImage Function:
    • Jimp is chosen over Sharp due to its less restrictive permission requirements. Unlike Sharp, Jimp does not require read access to the current working directory, making it more suitable for environments where granting such permissions is problematic.
    • Asynchronously processes an image buffer to resize the image to 800x800 pixels using bilinear resizing and converts it to JPEG format with 80% quality.
    • Checks if the resulting JPEG buffer exceeds 2MB; if so, it logs an error and returns null.
    • Handles errors during the image processing and logs them.
  4. downloadImage Function:
    • Asynchronously downloads an image from a provided URL using the Todoist API key for authorization (oops, I did not remember this initially and debugged this error for a long time!).
    • After downloading, it passes the image buffer to resizeAndConvertImage for resizing and conversion.
    • Handles HTTP errors and logs them, returning null if the download or processing fails.

Get image comments information from Todoist

async function getCommentsForTask(taskId) {
  try {
    const comments = await todoistapi.getComments({ taskId });
    return comments;
  } catch (error) {
    console.error(`Error fetching comments for task ${taskId}:`, error);
    return [];
  }
}

The getCommentsForTask function is designed to retrieve comments associated with a specific task in Todoist. Here’s a concise breakdown:

  • Function Purpose: Fetches comments for a given taskId from Todoist.
  • Parameters: Accepts taskId as an input, which is the identifier for the Todoist task.
  • Process:
    • The function makes an asynchronous call to todoistapi.getComments, passing an object with taskId.
    • If successful, it returns the comments array.
    • If an error occurs during the fetch operation, it logs the error and returns an empty array to ensure the function handles failures gracefully.
  • Error Handling: Errors are caught in the catch block, logged with a descriptive message including the taskId, and the function returns an empty array as a fallback.

This function is essential for integrating comment data from Todoist into other parts of the application, allowing for enhanced task management and user interaction.

Create the zod schema

async function createZodSchema(habitKeys, specialPrompt) {
  const transformDate = (date) => {
    if (!date.includes("T")) {
      date = date + "T23:00:00"; // if no time is included, consider it completed at 11pm
    }
    const parsedDate = Date.parse(date);
    const dateObj = isNaN(parsedDate) ? new Date() : new Date(parsedDate);

    const pad = (num) => String(num).padStart(2, "0");

    const year = dateObj.getFullYear();
    const month = pad(dateObj.getMonth() + 1);
    const day = pad(dateObj.getDate());
    const hours = pad(dateObj.getHours());
    const minutes = pad(dateObj.getMinutes());
    const seconds = pad(dateObj.getSeconds());
    // console.log(date);

    let offsetSign, offsetHours, offsetMinutes;
    const [datePart, timePart] = date.split("T");

    if (
      date.endsWith("Z")
      || !timePart
      || (timePart && !timePart.includes("+") && !timePart.includes("-"))
    ) {
      // Create a DateTimeFormat object for Pacific Time
      const pacificTime = new Intl.DateTimeFormat("en-US", {
        timeZone: DEF_TIMEZONE,
        hour: "2-digit",
        minute: "2-digit",
        second: "2-digit",
        hour12: false,
      });

      // Format the date to get the correct Pacific Time offset
      const pacificParts = pacificTime.formatToParts(dateObj);
      const pacificHours = parseInt(
        pacificParts.find((p) => p.type === "hour").value,
        10,
      );
      const pacificMinutes = parseInt(
        pacificParts.find((p) => p.type === "minute").value,
        10,
      );

      // Calculate the offset in minutes between the local time and UTC
      let offset = (pacificHours - dateObj.getUTCHours()) * 60
        + (pacificMinutes - dateObj.getUTCMinutes());

      // Adjust for day boundary crossing
      if (offset > 720) {
        offset -= 1440; // Subtract a full day in minutes
      } else if (offset < -720) {
        offset += 1440; // Add a full day in minutes
      }
      const absOffset = Math.abs(offset);
      offsetHours = pad(Math.floor(absOffset / 60));
      offsetMinutes = pad(absOffset % 60);
      offsetSign = offset <= 0 ? "-" : "+";
    } else {
      const offset = dateObj.getTimezoneOffset();
      const absOffset = Math.abs(offset);
      offsetHours = pad(Math.floor(absOffset / 60));
      offsetMinutes = pad(absOffset % 60);
      offsetSign = offset <= 0 ? "+" : "-";
    }

    return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}${offsetSign}${offsetHours}:${offsetMinutes}`;
  };

  let habitSchema;

  if (specialPrompt === "only_text" || specialPrompt === "only_image") {
    habitSchema = z.object({
      name: z.enum(habitKeys),
      note: z.string().default(""),
      date: z.string().transform(transformDate),
    });
  } else if (
    specialPrompt
    && (specialPrompt.includes("calorie") || specialPrompt.includes("food intake"))
  ) {
    habitSchema = z.object({
      unit: z.string().default("kCal"),
      name: z.enum(habitKeys),
      value: z.number().default(1),
      note: z.string().default(""),
      date: z.string().transform(transformDate),
    });
  } else {
    habitSchema = z.object({
      unit: z.string().default("rep"),
      name: z.enum(habitKeys),
      value: z.number().default(1),
      note: z.string().default(""),
      date: z.string().transform(transformDate),
    });
  }

  // Define the schema for the list of habits
  const habitsListSchema = z.array(habitSchema);

  return z.object({ habits: habitsListSchema });
}

The createZodSchema function dynamically constructs a Zod schema based on provided habit keys and a special prompt. This schema is used to validate and parse data related to habits. Here’s a detailed explanation:

  1. Function Purpose: Creates a Zod schema for validating habit data, including names, notes, dates, and optionally, units and values.
  2. Parameters:
    • habitKeys: An array of strings representing valid habit names.
    • specialPrompt: A string that modifies the schema based on specific requirements (e.g., handling only text or image data, or tracking calorie intake).
  3. Date Transformation:
    • transformDate: A nested function that adjusts a given date string to include a time component if missing, parses it, and formats it into an ISO string with a timezone offset. The timezone handling accounts for both UTC and specific timezones like Pacific Time.
  4. Schema Construction:
    • Depending on the specialPrompt, the function constructs different Zod object schemas:
      • For prompts like "only_text" or "only_image", the schema includes name, note, and date.
      • For prompts related to calorie tracking, it includes unit, name, value, note, and date.
      • The default schema includes unit, name, value, note, and date, with defaults set for unit, value, and note.
  5. Schema Compilation:
    • A list schema (habitsListSchema) is defined to handle an array of habits according to the constructed habit schema.
    • The final schema returned is an object containing this list schema under the key habits.

This function is crucial for ensuring that habit data conforms to expected formats and values, facilitating reliable data processing and storage.

Get habitify habits detail

async function get_habitify_database()
{
  const habits_list = await blob.getJSON("habitify_database");
  if (!habits_list || force_update_database)
  {
    const HABITIFY_API_KEY = process.env.HABITIFY_API_KEY;
    const url = "https://api.habitify.me/habits";
    const headers = {
      Authorization: HABITIFY_API_KEY,
      "Content-Type": "application/json",
    };

    try {
      const response = await fetch(url, { method: "GET", headers: headers });
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      const jsonResponse = await response.json();

      // Filter habits
      const filteredHabits = jsonResponse.data.filter(
        (habit) => !habit.is_archived && habit.log_method === "manual",
      );

      // Group habits by area
      const habitsByArea = {};
      filteredHabits.forEach((habit) => {
        const area = habit.area && Object.keys(habit.area).length > 0
          ? habit.area.name
          : "undefined";
        if (!habitsByArea[area]) {
          habitsByArea[area] = {};
        }
        habitsByArea[area][habit.name] = {
          id: habit.id,
          name: habit.name,
          unit_type: habit.goal.unit_type,
        };
        if (area !== "undefined") {
          if (!habitsByArea["undefined"]) {
            habitsByArea["undefined"] = {};
          }
          habitsByArea["undefined"][habit.name] = {
            id: habit.id,
            name: habit.name,
            unit_type: habit.goal.unit_type,
          };
        }
      });
      // Log the result JSON
      console.log(JSON.stringify(habitsByArea, null, 2));
      await blob.setJSON("habitify_database", habitsByArea);
      return habitsByArea;
    } catch (error) {
      console.error("Error:", error);
      process.exit(1);
    }
  }
  else
  {
    return habits_list;
  }
}

The get_habitify_database function retrieves and processes habit data from Habitify, caching it locally for efficiency. Here’s a concise breakdown:

  1. Function Purpose: Fetches habit details from the Habitify API and processes them into a structured format, grouping habits by their respective areas.
  2. Data Retrieval and Caching:
    • Initially, the function attempts to load the habits list from a local cache using blob.getJSON("habitify_database").
    • If the cache is empty or force_update_database is true, it proceeds to fetch data from the Habitify API.
  3. API Interaction:
    • Constructs the API request using the Habitify API key and sends a GET request to the Habitify habits endpoint.
    • If the response is successful, it processes the JSON data; otherwise, it throws an error with the HTTP status.
  4. Data Processing:
    • Filters out archived habits and those not logged manually.
    • Groups habits by their area, creating a nested object where each area contains its habits, identified by name, ID, and unit type.
    • Any habit whether it does or does not belong to a specific area, it is grouped under "undefined".
  5. Error Handling and Logging:
    • Errors during API interaction or data processing are logged, and the process exits with an error status.
    • Successfully processed data is logged for verification and then cached using blob.setJSON.
  6. Return Value:
    • Returns the structured habit data, either from the cache or freshly fetched and processed from the API.

Use LLMs to process text into matched habits

function createPrompt(habitKeys_str, specialPrompt) {
  let prompt =
    "You are processing content into a list of habit objects. Based on the options of habits, infer appropriate values for the fields:\n";
  prompt += "The habits you can choose from are: " + habitKeys_str + ".\n";

  if (specialPrompt === "only_image" || specialPrompt === "only_text") {
    prompt +=
      "You are extracting the name of the habit, the date of the habit (if mentioned, or otherwise an empty string), and any note if it exists about a habit (if mentioned, or otherwise an empty string). Remember, a note is associated with only 1 habit and will say 'note that' or something like that.\n";
  } else if (
    specialPrompt
    && (specialPrompt.includes("calorie") || specialPrompt.includes("food intake"))
  ) {
    prompt +=
      "You are tracking habits related to calorie or food intake. You are extracting the name of the habit which will be something like 'track calories' or 'track food intake'. As the next step, estimate the calories for the food mentioned in the text as and use that as the value, the unit will be kCal, the date of the habit (if mentioned, or otherwise an empty string), and the food mentioned in the text will be the note for the habit along with the estimated calories in paranthesis.\n";
  } else {
    prompt +=
      "You are choosing the unit (like min or kg), the default is rep. You are choosing the value of the habit, the default is 1. You are getting the date of the habit, if mentioned, or otherwise an empty string. You are extracting any note if it exists about a habit, if mentioned, or otherwise an empty string. Remember, a note is associated with only 1 habit and will say 'note that' or something like that.\n";
  }

  prompt += "Infer and in step 1 ";
  prompt = prompt
    + (specialPrompt
        && (specialPrompt.includes("calorie") || specialPrompt.includes("food intake"))
      ? "a list with a SINGLE habit object that matches the calorie or food intake option from valid options"
      : "create a list of all the habits extracted from text that match the options")
    + "; and then as step 2 assign values to each of these habits' properties based on the provided content and the aforementioned context.";
  return prompt;
}
async function process_text(habits_list, text, specialPrompt) {
  // console.log(habits_list);
  const habitKeys = Object.keys(habits_list);
  const zod_schema = await createZodSchema(habitKeys, specialPrompt);

  const sys_prompt = createPrompt(habitKeys.join(), specialPrompt);
  const processed_message = await client.chat.completions.create({
    messages: [
      { role: "system", content: sys_prompt },
      { role: "user", content: text },
    ],
    model: "gpt-4o",
    response_model: {
      schema: zod_schema,
      name: "habit and value extraction",
    },
    max_retries: 3,
  });

  return processed_message;
}

This code snippet consists of two functions, createPrompt and process_text, which uses large language models (LLMs) to process text and extract habit-related information based on specified criteria.

  1. createPrompt Function:
    • Purpose: Generates a text prompt for an LLM based on available habit keys and a special prompt condition.
    • Parameters:
      • habitKeys_str: A string containing all possible habit names, concatenated and separated by commas.
      • specialPrompt: A string that specifies the type of processing required, such as handling only text or image data, or focusing on calorie intake.
    • Behavior:
      • Constructs a base prompt explaining the task of processing content into habit objects.
      • Modifies the prompt based on specialPrompt to provide specific instructions for handling different types of data (e.g., extracting names, dates, notes, or calorie estimates).
      • Concludes with instructions to infer and assign values to habit properties in a structured format.
  2. process_text Function:
    • Purpose: Processes a given text to match and extract habit data using an LLM, guided by the generated prompt.
    • Parameters:
      • habits_list: An object containing habit data.
      • text: The text input that needs to be processed.
      • specialPrompt: A string indicating special processing requirements.
    • Process:
      • Extracts habit keys from habits_list.
      • Calls createZodSchema to generate a Zod schema based on habit keys and the special prompt.
      • Generates a system prompt using createPrompt.
      • Submits the system prompt and user text to an LLM (specified as gpt-4o) for processing, structured according to the generated Zod schema.
      • Attempts up to three retries if necessary.
    • Return Value:
      • Returns the processed message from the LLM, which includes extracted and structured habit data.
This is where the magic happens

Add logs, image notes or text notes

async function addLog(habit_id, unit_type, value, target_date) {
  const url = `https://api.habitify.me/logs/${habit_id}`;
  const headers = {
    Authorization: HABITIFY_API_KEY,
    "Content-Type": "application/json",
  };
  const body = {
    unit_type: unit_type,
    value: value,
    target_date: target_date,
  };

  try {
    const response = await fetch(url, {
      method: "POST",
      headers: headers,
      body: JSON.stringify(body),
    });
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    const jsonResponse = await response.json();
    return jsonResponse;
  } catch (error) {
    console.error("Error:", error);
    process.exit(1);
  }
}
async function addTextNote(habit_id, created, content) {
  const url = `https://api.habitify.me/notes/${habit_id}`;
  const headers = {
    Authorization: HABITIFY_API_KEY,
    "Content-Type": "application/json",
  };
  const body = {
    created: created,
    content: content,
  };

  try {
    const response = await fetch(url, {
      method: "POST",
      headers: headers,
      body: JSON.stringify(body),
    });
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    const jsonResponse = await response.json();
    return jsonResponse;
  } catch (error) {
    console.error("Error:", error);
    process.exit(1);
  }
}

async function addImageNote(habit_id, created_at, imageBuffer) {
  const url = `https://api.habitify.me/notes/addImageNote/${habit_id}?created_at=${encodeURIComponent(created_at)}`;
  const headers = {
    Authorization: HABITIFY_API_KEY,
    "Content-Type": "image/jpeg",
  };

  try {
    const response = await fetch(url, {
      method: "POST",
      headers: headers,
      body: imageBuffer,
    });
    if (!response.ok) {
      console.log(response);
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    const jsonResponse = await response.json();
    return jsonResponse;
  } catch (error) {
    console.error("Error:", error);
    process.exit(1);
  }
}

This code snippet defines three asynchronous functions (addLog, addTextNote, and addImageNote) that interact with the Habitify API to add different types of entries: logs, text notes, and image notes. Here's a detailed breakdown of each function:

  1. addLog Function:
    • Purpose: Adds a log entry for a specific habit.
    • Parameters:
      • habit_id: Identifier for the habit.
      • unit_type: Type of measurement unit for the log.
      • value: Value of the log entry.
      • target_date: Date for which the log is applicable.
    • Process:
      • Constructs a POST request to the Habitify logs endpoint with the habit ID.
      • Sends the request with headers for authorization and content type, and a body containing the log details.
      • Handles the response, throwing an error if the request is unsuccessful.
    • Return Value:
      • Returns the JSON response from the API if successful.
  2. addTextNote Function:
    • Purpose: Adds a text note to a specific habit.
    • Parameters:
      • habit_id: Identifier for the habit.
      • created: Timestamp for when the note was created.
      • content: Content of the text note.
    • Process:
      • Constructs a POST request to the Habitify notes endpoint with the habit ID.
      • Sends the request with appropriate headers and a body containing the note details.
      • Manages the response, throwing an error for unsuccessful requests.
    • Return Value:
      • Returns the JSON response from the API if successful.
  3. addImageNote Function:
    • Purpose: Adds an image note to a specific habit.
    • Parameters:
      • habit_id: Identifier for the habit.
      • created_at: Timestamp for when the image note was created.
      • imageBuffer: Buffer containing the image data.
    • Process:
      • Constructs a POST request to a specialized Habitify image notes endpoint with the habit ID and created timestamp.
      • Sends the request with headers set for authorization and content type set to "image/jpeg", along with the image buffer as the body.
      • Manages the response, logging the full response on error and throwing an error for unsuccessful requests.
    • Return Value:
      • Returns the JSON response from the API if successful.

Each function is designed to handle specific types of data entries in Habitify, ensuring that logs, text notes, and image notes are correctly added to the system with robust error handling and response management.

Main function

export default async function(interval: Interval) {
  const habits_list = await get_habitify_database();
  var tasks = await todoistapi.getTasks({
    projectId: add_to_habitify_todoist_project_id,
  });

  for (const task of tasks) {
    console.log(task);
    task.imageComments = null;
    if (task.commentCount > 0) {
      const imageComments = [];
      const todoist_comments = await getCommentsForTask(task.id);
      for (const comment of todoist_comments) {
        // console.log(comment);
        if (comment.attachment && comment.attachment.resourceType === "image") {
          const imageBuffer = await downloadImage(comment.attachment.image);
          if (imageBuffer) {
            imageComments.push(imageBuffer);
          }
        }
      }
      task.imageComments = imageComments ? imageComments : null;
    }

    /// START---REMOVE ONCE UPLOAD IS FIXED
    if (task.imageComments) {
      continue;
    }
    /// END---REMOVE ONCE UPLOAD IS FIXED

    const [habitifyAreaName, specialPrompt] = getHabitifyAreaName(
      task.sectionId,
    );
    const processed_task = await process_text(
      habits_list[habitifyAreaName],
      task.content
        + " dated "
        + convertDateObject(task.due)
        + "\n"
        + task.description,
      specialPrompt,
    );
    const habits = processed_task.habits;
    console.log(habits);
    for (const habit of habits) {
      if (specialPrompt != "only_image" || specialPrompt != "only_text") {
        await addLog(
          habits_list[habitifyAreaName][habit.name].id,
          habit.unit,
          habit.value,
          habit.date,
        );
      }
      if (habit.note) {
        await addTextNote(habits_list[habitifyAreaName][habit.name].id, habit.date, habit.note);
      }
      if (task.imageComments) {
        for (const imageComment of task.imageComments) {
          await addImageNote(
            habits_list[habitifyAreaName][habit.name].id,
            habit.date,
            imageComment,
          );
        }
      }
    }
    await todoistapi.deleteTask(task.id);
  }
}

The main function brings together the integration between Todoist and Habitify, processing tasks from Todoist and logging corresponding activities in Habitify based on various criteria. Here's a concise breakdown of its operations:

  1. Function Overview:
    • Purpose: Synchronizes tasks from Todoist with Habitify by processing text and images associated with tasks and logging them as habits.
    • Parameters:
      • interval: An object that might specify the frequency or timing for running this function, although its usage isn't explicitly shown in the snippet.
  2. Process Flow:
    • Retrieve Habit Data: Fetches a list of habits from Habitify.
    • Fetch Todoist Tasks: Retrieves tasks from a specific project in Todoist.
    • Task Processing:
      • Iterates over each task from Todoist.
      • Logs task details for debugging.
      • Initializes or clears imageComments.
      • If a task has comments, it fetches these comments and downloads any images attached, storing them in imageComments.
    • Text and Image Processing:
      • Skips further processing if there are image comments (temporary measure noted for removal).
      • Retrieves the Habitify area name and special prompt based on the task's section ID.
      • Processes the task's content, due date, and description to match and extract habit data.
    • Log Habits in Habitify:
      • Iterates over the extracted habits.
      • Depending on the specialPrompt, logs the habit data in Habitify using addLog and addTextNote.
      • If there are image comments, each image is logged as an image note in Habitify using addImageNote.
    • Task Cleanup:
      • Deletes the processed task from Todoist.
  3. Error Handling:

    The function includes try-catch blocks within called functions but does not explicitly handle errors in the main flow. It relies on the robustness of the individual functions to manage exceptions. If there are exceptions in logging, it just stops the program from running 🤷‍♀️.


I have done something similar to add to Notion from Todoist, with AI (Use Notion’s Property Description As Text → DB add-itor) and without (Add to Notion through Todoist). Go check that out!

If you found this useful, please consider buying me a coffee here: