DEV Community

loading...
Cover image for Creating a Habit Tracker App using Typescript, React, and Tailwind

Creating a Habit Tracker App using Typescript, React, and Tailwind

zackderose profile image Zack DeRose ・10 min read

What We're Building!

In a previous article, I had mentioned creating a 'morning habit stack' as part of my on-going effort this year to establish good habits and become a more productive person.

As a part of wanting to establish this habit, I figured I'd use the "Seinfeld Calendar" approach - but being a web developer, I'd kinda prefer to make some sort of online tool for tracking my habit... so let's do it!!

[Most of my efforts are fueled by the book "Atomic Habits" by James Clear - if you're interested in learning more!!]

How it will look!

Breaking Down the Problem

A critical skill [maybe THE critical skill] in our line of work is breaking down a problem into logical pieces. Let's follow this exercise for this small example:

  1. Determine the 'shape' of our data, both in how we'd like to "store" the data, and how we'd like to use the data within a Calendar component.
  2. Create functions to convert the "stored" shape of our data to the shape we'd like to use in our components.
  3. Create a React Component for our Calendar, with appropriately accessible HTML elements (good bones are important!!)
  4. Add tailwind utility classes to improve our presentation!

First Step: Determine the "Shape" of our Data!

How it will look!

Looking at our desired output, let's focus first on determining the way we'd like to store data for our calendar. I think there are likely many valid ways we could take this - but going with a basic and simple approach, I think the following Typescript interface covers most of our bases:

export interface HabitTrackerProps {
  startDate: {
    dayOfTheWeek: string,
    month: string,
    date: number
  },
  endDate: {
    dayOfTheWeek: string,
    month: string,
    date: number
  },
  results: string[]
}
Enter fullscreen mode Exit fullscreen mode

Given this information, we should be able to determine everything we'd need to display the calendar view shown at the start of this section.

Better Types, plus Iterables!

To enhance this typing, we can add the following:

export DAYS_OF_THE_WEEK = [
  'Sunday',
  'Monday',
  'Tuesday',
  'Wednesday',
  'Thursday',
  'Friday',
  'Saturday',
] as const;
export type DayOfTheWeek = typeof daysOfTheWeek[number];

export const months = [
  'January',
  'February',
  'March',
  'April',
  'May',
  'June',
  'July',
  'August',
  'September',
  'October',
  'November',
  'December',
] as const;
export type Month = typeof months[number];
Enter fullscreen mode Exit fullscreen mode

The as const syntax here signals to our typing system that these arrays are readonly, which allows us to create a union type from the arrays!

Displaying Intellisence on the DayOfTheWeek type.

This is helpful as it gives us a proper type, as well as an Iterable, that we'll see come in handy in future sections!

Let's also define a union type for our results to make that a bit more clear-cut:

export type HabitResult = 'success' | 'failure';
Enter fullscreen mode Exit fullscreen mode

With these changes, we can now enhance our typing:

interface CalendarDate {
  dayOfTheWeek: DayOfTheWeek,
  date: number,
  month: Month
}

export interface CalendarProps {
  startDate: CalendarDate,
  endDate: CalendarDate,
  results: HabitResult[]
}
Enter fullscreen mode Exit fullscreen mode

Data Format for our "Template"

The data model we've set up is simple enough for storage I'd wager! It's pretty minimal in what it holds (we probably could remove the day of the week and add a year, and then extrapolate the day of the week from the other info... but this works for our use-case I think). The data is fairly human-readable as well, while still passing the 5-second rule of comprehension:

{
  "startDate": {
    "month": "February",
    "date": 4,
    "dayOfTheWeek": "Thursday",
  },
  "endDate": {
    "month": "March",
    "date": 21,
    "dayOfTheWeek": "Sunday",
  },
  "results": [
    "success",
    "success",
    "success"
  ]
}
Enter fullscreen mode Exit fullscreen mode

When it comes to the data we'd like to work with in 'templating' out our calendar component in tsx, we'd like to refine this data a bit to make it easier to work with! Here's what I'd like to see personally:

const data = {
  'week-1': {
    Sunday: {
      month: 'January',
      date: 31,
      result: 'out of bounds',
    },
    // ...
  },
  'week-2': {
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

If this were fully expanded, it would definitely start surpassing the capacities of a human brain (well, mine at least!) but its perfect data format for our computers to iterate through as we create our DOM nodes!

To type this properly, let's reach for the Record utility type from Typescript. I definitely advise reading the official docs here! But the short version is a Record<keyType, valueType> will specify an object where all the keys conform to the keyValue and all values conform to the valueType, AND FURTHERMORE - if the keyType is a union type, then it will assert that a key exists for EVERY type in the union type!

This is perfect for our 'days of the week' use-case:

export interface HabitTrackerDay {
  month: Month,
  date: number,
  result: ResultToDisplay
}

export type HabitTrackerWeek =
  Record<DayOfTheWeek, HabitTrackerDay>
Enter fullscreen mode Exit fullscreen mode

Also looking at the ResultToDisplay type, we'd like for this to support all possibilities of the HabitTrackerResult, but we also probably need a out of bounds and a no result yet option here to support everything our UI requires! To do this, lets create that type:

export type ResultToDisplay = 
  | HabitTrackerResult
  | 'out of bounds'
  | 'no result yet';
Enter fullscreen mode Exit fullscreen mode

Now that we have a week, let's create the following type for all our data:

export type HabitTrackerData =
  Record<string, HabitTrackerWeek>;
Enter fullscreen mode Exit fullscreen mode

This will allow us to create an object with our week names mapped to a week's chunk of data. To supplement this data, we'll also probably want a list of all week names to iterate though. We could create this from this object (Object.keys(habitTrackerData)), but might as well supply it to our template, so as to keep it as simple as possible. We'll also want the streak information! This can be determined only by the HabitResult array, but we'll throw it all together to give us the following:

export interface HabitTrackerTemplateData {
  weekNames: string[],
  data: HabitTrackerData,
  maxSuccessStreak: number,
  maxFailureStreak: number
}
Enter fullscreen mode Exit fullscreen mode

We've hardly written anything in the way of implementation at this point, but we now have a solid data model that we're expressing in our TypeScript code! The rest will start to fall into place at this point!

Part 2: Converting From CalendarProps to HabitTrackerTemplateData

Let's start with the following:

export function createHabitTrackerTemplateData(props: CalendarProps): HabitTrackerTemplateData {
  //...
}
Enter fullscreen mode Exit fullscreen mode

So, here's the cool thing about our solution - at this point, we definitely could skip ahead to steps 3 && 4 and leave this unimplemented! (Maybe have it return an example of the desired data)

This is all benefit of the 'work' we did in step 1 to set up our data models. Since we're here though, we might as well set up the problem.

Since we'd like to have general confidence in our solution, we might as well start with a unit test to assert that our implementation on this function is correct:

import {
  CalendarProps,
  createHabitTrackerTemplateData,
  HabitTrackerTemplateData,
} from './calendar.utils';

interface TestParams {
  input: CalendarProps;
  output: HabitTrackerTemplateData;
}

function testCreatingTemplateData(
  { input, output }: TestParams
) {
  expect(
    createHabitTrackerTemplateData(input)
  ).toEqual(output);
}

describe('createHabitTrackerTemplateData()', () => {
  test('known example', () => {
    testCreatingTemplateData({
      input: {
        startDay: {
          month: 'February',
          date: 4,
          dayOfTheWeek: 'Thursday'
      },
      endDay: {
        month: 'March',
        date: 21,
        dayOfTheWeek: 'Sunday'
      },
      results: ['success', 'failure', 'success', 'success'],
    },
    output: {
      weekNames: [
        'week-1',
        'week-2',
        'week-3',
        'week-4',
        'week-5',
        'week-6',
        'week-7',
        'week-8',
      ],
      data: { /* much too big to show here */ },
      maxSuccessStreak: 2,
      maxFailureStreak: 1
    }
  });
});
Enter fullscreen mode Exit fullscreen mode

This will give us a red/green check to run while we fill out our implementation!

When it comes to our actual implementation, let's start with the streak information. Streak info is a function of the results array, so we can create a smaller piece of functionality that focuses only on this:

function determineStreakInfo(
  results: HabitResult[]
): { maxSuccessStreak: number; maxFailureStreak: number } {
  let maxSuccessStreak = 0;
  let maxFailureStreak = 0;
  const currentStreak: {
    kind: HabitResult;
    count: number
  } = { kind: 'success', count: 0 };
  for (const result of results) {
    if (result === currentStreak.kind) {
      currentStreak.count++;
    } else {
      currentStreak = { kind: result, count: 1 };
    }
    if (result === 'success' && currentStreak.count > maxSuccessStreak) {
      maxSuccessStreak = currentStreak.count;
    }
    if (result === 'failure' && currentStreak.count > maxFailureStreak) {
      maxFailureStreak = currentStreak.count;
    }
  }
  return { maxFailureStreak, maxSuccessStreak };
}
Enter fullscreen mode Exit fullscreen mode

Next, we'll need to build our HabitTrackerData object. Thinking through this problem, the general algorithm here would be:

  1. start with a pointer at the provided first day
  2. create a 'reverse' pointer and loop backwards one day at a time until you hit a 'Sunday' (the first day of the week), adding 'out of bounds' days to our object as we go.
  3. Go back to our original pointer, and advance this pointer forward one day at a time until we hit the provided end day, adding either the data from the provided results array, or 'no result yet' if the array isn't large enough to include the given day.
  4. Continue to advance the pointer one day at a time, until we hit a 'Saturday' (last day of the week), adding 'out of bounds' days to our object as we go.

All the while, keeping a record of the # week we're on, and advancing that when the pointer goes from a 'Saturday' to a 'Sunday'.

This is a fairly messy implementation (most implementations involving dates are!) but we can make it happen! Let's start with some utilities we know we'll need based on this implementation:

  • a function that takes a CalendarDate and returns the previous CalendarDate
  • a function that takes a CalendarDate and returns the next CalendarDate

To create these properly, we'll also need a map of the number of days per month, as it will affect the date when going backwards, and when to switch to the next month when going forwards:

const daysPerMonth: Record<Month, number> = {
  January: 31,
  February: 28,
  March: 31,
  April: 30,
  May: 31,
  June: 30,
  July: 31,
  August: 31,
  September: 30,
  October: 31,
  November: 30,
  December: 31,
};
function nextMonth(month: Month): Month {
  if (month === 'December') {
    return 'January';
  }
  return months[months.indexOf(month) + 1];
}
function nextDayOfWeek(day: DayOfTheWeek): DayOfTheWeek {
  if (day === 'Saturday') {
    return 'Sunday';
  }
  return daysOfTheWeek[daysOfTheWeek.indexOf(day) + 1];
}
function nextCalendarDay(calendarDay: CalendarDate): CalendarDate {
  if (calendarDay.date === daysPerMonth[calendarDay.month]) {
    return {
      month: nextMonth(calendarDay.month),
      date: 1,
      dayOfTheWeek: nextDayOfWeek(calendarDay.dayOfTheWeek),
    };
  }
  return {
    month: calendarDay.month,
    date: calendarDay.date + 1,
    dayOfTheWeek: nextDayOfWeek(calendarDay.dayOfTheWeek),
  };
}
function previousMonth(month: Month): Month {
  if (month === 'January') {
    return 'December';
  }
  return months[months.indexOf(month) - 1];
}
function previousDate(calendarDay: CalendarDate): number {
  if (calendarDay.date === 1) {
    return daysPerMonth[previousMonth(calendarDay.month)];
  }
  return calendarDay.date - 1;
}
function previousDay(day: DayOfTheWeek): DayOfTheWeek {
  if (day === 'Sunday') {
    return 'Saturday';
  }
  return daysOfTheWeek[daysOfTheWeek.indexOf(day) - 1];
}
function previousCalendarDay(calendarDay: CalendarDate): CalendarDate {
  return {
    dayOfTheWeek: previousDay(calendarDay.dayOfTheWeek),
    date: previousDate(calendarDay),
    month:
      calendarDay.date === 1
        ? previousMonth(calendarDay.month)
        : calendarDay.month,
  };
}
Enter fullscreen mode Exit fullscreen mode

As complex as this is already - we're still not accommodating for leap years... I'm not going to sweat it for now, but! We could in the future (maybe for 2024!) adjust our map of months to days in the month to instead point to a function that returns a number - the idea being that the function would take the year as a parameter, and we could then use the Gregorian calendar logic to determine the proper number of days for February based on that (the function for all other months would ignore any parameters and return the value they current point to).

And now my implementation:

export function createHabitTrackerTemplateData({
  startDay,
  endDay,
  results,
}: CalendarProps): HabitTrackerTemplateData {
  const weekNames = ['week-1'];

  // go backwards until you hit a 'Sunday'
  const firstWeekOutOfBoundsDates = {} as any;
  let firstWeekPointer = { ...startDay };
  while (firstWeekPointer.dayOfTheWeek !== 'Sunday') {
    firstWeekPointer = previousCalendarDay(firstWeekPointer);
    firstWeekOutOfBoundsDates[firstWeekPointer.dayOfTheWeek] = {
      month: firstWeekPointer.month,
      date: firstWeekPointer.date,
      result: 'out of bounds',
    } as const;
  }

  // go forwards day by day, populating from the provided
  // `results` array, until you hit the provided `endDay`
  const data: Record<
    string,
    Record<DayOfTheWeek, { month: Month; date: number; result: DisplayResult }>
  > = {
    'week-1': { ...firstWeekOutOfBoundsDates } as any,
  };
  let dayIndex = 0;
  let dayPointer = { ...startDay };
  let weekCounter = 0;
  while (dayPointer.date !== endDay.date || dayPointer.month !== endDay.month) {
    data[`week-${weekCounter + 1}`][dayPointer.dayOfTheWeek] = {
      month: dayPointer.month,
      date: dayPointer.date,
      result: results[dayIndex] || 'no result yet',
    };
    dayPointer = nextCalendarDay(dayPointer);
    dayIndex++;
    if (dayPointer.dayOfTheWeek === 'Sunday') {
      weekCounter++;
      const newWeekName = `week-${weekCounter + 1}`;
      weekNames.push(newWeekName);
      data[newWeekName] = {} as any;
    }
  }
  data[`week-${weekCounter + 1}`][dayPointer.dayOfTheWeek] = {
    month: dayPointer.month,
    date: dayPointer.date,
    result: results[dayIndex] || 'no result yet',
  };

  // keep going forwards until you hit a `Saturday`
  while (dayPointer.dayOfTheWeek !== 'Saturday') {
    dayPointer = nextCalendarDay(dayPointer);
    data[`week-${weekCounter + 1}`][dayPointer.dayOfTheWeek] = {
      month: dayPointer.month,
      date: dayPointer.date,
      result: 'out of bounds',
    };
  }
  return {
    data,
    weekNames,
    ...determineStreakInfo(results)
  };
}
Enter fullscreen mode Exit fullscreen mode

I'm not crazy with this implementation - it certainly doesn't pass a 5 second rule (maybe not even a 5 minute rule...) but our test is going green with this in place, which gives me the general confidence to proceed.

The cool thing here is we have some great general utility functions available now - that actually could just as easily be used in Angular or any other JS framework!

Part 3: Creating a React Component

Breaking down our React Component, we'll want to:

  1. define our props as the HabitTrackerProps type we created in the first part
  2. call our createHabitTrackerTemplateData(), passing in those props, and destructuring out the properties
  3. create our component template in tsx, by map()ing over all weeknames, and then inside of that map()ing over all days of the week, creating a <div> for each day
  4. if the day was a 'success', set the background image to that div to the url of a green check - or a red x if it was a 'failure'.
  5. Add streak information at the bottom of all these divs!

Here's how that looks in practice:

const GREEN_CHECK_URL = 'some_image_url';
const RED_X_URL = 'some_image_url';

export function Calendar(props: CalendarProps) {
  const {
    weekNames,
    data,
    maxSuccessStreak,
    maxFailureStreak,
  } = createHabitTrackerTemplateData(props);
  return (
    <>
      <div>
        {weekNames.map((weekName) =>
          daysOfTheWeek.map((dayOfTheWeek) => {
            const { date, result } = data[weekName][dayOfTheWeek];
            return (
              <div
                key={`${weekName}|${dayOfTheWeek}`}
                style={{
                  backgroundImage: `url(${
                    result === 'success'
                      ? GREEN_CHECK_URL
                      : result === 'failure'
                      ? RED_X_URL
                      : undefined
                  })`,
                  backgroundSize: '100%',
                }}
              >
                <div>
                  {date}
                </div>
              </div>
            );
          })
        )}
      </div>
      <h2>
        Max Success Streak: {maxSuccessStreak}
      </h2>
      <h2>
        Max Failure Streak: {maxFailureStreak}
      </h2>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Add Tailwind Styles

At this point, we've got solid bones to our html, but actually a relatively un-usable presentation so far. We'll use Tailwind as a style system to get this up to at least passable quickly!

Here's the highlights of the tailwind goals for this component:

  • create a 7-column grid - to present our calendar
  • make the size of our <div>'s reactive to the screen size, by setting a small default size, but increasing this (with the md: modifier) once the screen size passes the "medium" threshold
  • Add borders to our day <div>'s - making them twice as thick on the edges to make the display consistant.
  • Add rounded corners to the borders on the corners of our calendar
  • Put the inner <div> with our # date in the top-left of the day <div> and give it a circle-appearance.
  • Center the streak information headers

Check out the source code of this stackblitz for the details on the implementation!

Conclusion && Potential Next Steps

And there we go! From nothing to something semi-cool :). As a developer with limited React experience, I'm pretty enthused in general regarding the simplicity of React.

An obvious next step would be to read our HabitTrackerProps data from some network call - or even better to push change events from the server to our client! I've got some ideas for this in the pipes...

Another interesting way to take this further would be to introduce Nx to the project. Currently, I'm intentionally using create-react-app to try and understand the general "React way" of doing things. But being able to introduce this code (especially the calendar.utils.ts file) would be great in terms of pretty easily getting up an Angular version of this component!

Also cool would be to share the entire React component as well - making it available for me to run it in a stand-alone app, but also bring it into my other sites as needed!

Discussion (0)

pic
Editor guide