DEV Community

Cover image for Designing an Activity Calendar in React
Purbayan Chowdhury
Purbayan Chowdhury

Posted on • Edited on

Designing an Activity Calendar in React

Activity Calendar has become popular after open-source platforms like Github, Kaggle starting used to show contributions data in this format. As a developer, people often tries to analyse what are the days I am productive the most. The measure might be a quantitative and not qualitative, but it gives a clear picture overall. We gonna build an Activity Calendar Component today in React from Scratch.

A Pacman Inspired Activity Calendar ASCII Art

Activity Calendar or Graph is a heatmap graph distribution over months and days of week.

Getting Started

Starting from the basic cra-template of creact-react app

ActivityCalendar.jsx

import React from 'react';

function ActivityCalendar({ children, style, ...otherProps }) {
    return (
        <article className="ActivityCalendar" style={style} {...otherProps}>
            <svg></svg>
            {children}
        </article>
    );
}

export default ActivityCalendar;
Enter fullscreen mode Exit fullscreen mode

This functional component has children and style props where children helps to populate any child component and style to populate with inline CSS style.

Implementation

Formatting Data

We need to have some utilities function to format data or generate an empty data. Few libraries that we need to install here are a colour utility library tinycolor2 and a date manipulation library date-fns.

npm install tinycolor2 date-fns
Enter fullscreen mode Exit fullscreen mode

NormalizeCalendarDays

util.js

import eachDayOfInterval from 'date-fns/eachDayOfInterval';
import formatISO from 'date-fns/formatISO';
import parseISO from 'date-fns/parseISO';

function normalizeCalendarDays(days) {
    const daysMap = days.reduce((map, day) => {
        map.set(day.date, day);
        return map;
    }, new Map());

    return eachDayOfInterval({
        start: parseISO(days[0].date),
        end: parseISO(days[days.length - 1].date),
    }).map((day) => {
        const date = formatISO(day, { representation: 'date' });

        if (daysMap.has(date)) {
            return daysMap.get(date);
        }

        return {
            date,
            count: 0,
            level: 0,
        };
    });
}

Enter fullscreen mode Exit fullscreen mode

The function normalizeCalendarDays takes in list of days and creates a daysMap for each date. The daysMap is used to get all the days from starting date to ending date, and if a date is missing in the array days, a default count and level of 0 is added to the resulting array against that date.

Group by Weeks

utils.js

...
import parseISO from 'date-fns/parseISO';
import differenceInCalendarDays from 'date-fns/differenceInCalendarDays';
import getDay from 'date-fns/getDay';
import subWeeks from 'date-fns/subWeeks';
import nextDay from 'date-fns/nextDay';

...
export function groupByWeeks(days, weekStart) {
    if (days.length === 0) return [];

    // The calendar expects a continuous sequence of days, so fill gaps with empty activity.
    const normalizedDays = normalizeCalendarDays(days);

    // Determine the first date of the calendar. If the first contribution date is not
    // specified week day the desired day one week earlier will be selected.
    const firstDate = parseISO(normalizedDays[0].date);
    const firstCalendarDate = getDay(firstDate) === weekStart ? firstDate : subWeeks(nextDay(firstDate, weekStart), 1);

    // In order to correctly group contributions by week it is necessary to left pad the list,
    // because the first date might not be desired week day.
    const paddedDays = [
        ...Array(differenceInCalendarDays(firstDate, firstCalendarDate)).fill(undefined),
        ...normalizedDays,
    ];

    return Array(Math.ceil(paddedDays.length / 7))
        .fill(undefined)
        .map((_, calendarWeek) => paddedDays.slice(calendarWeek * 7, calendarWeek * 7 + 7));
}
Enter fullscreen mode Exit fullscreen mode

The function groupByWeeks takes in list of days (days) and weekStart as input parameters where it groups the given list of days into weeks starting with weekStart.

Get Labels for Months

utils.js

...
import nextDay from 'date-fns/nextDay';
import getMonth from 'date-fns/getMonth';
...

export const MIN_DISTANCE_MONTH_LABELS = 2;
export const DEFAULT_MONTH_LABELS = [
    'Jan',
    'Feb',
    'Mar',
    'Apr',
    'May',
    'Jun',
    'Jul',
    'Aug',
    'Sep',
    'Oct',
    'Nov',
    'Dec',
];

...
export function getMonthLabels(weeks, monthNames = DEFAULT_MONTH_LABELS) {
    return weeks
        .reduce((labels, week, index) => {
            const firstWeekDay = week.find((day) => day !== undefined);

            if (!firstWeekDay) {
                throw new Error(`Unexpected error: Week is empty: [${week}]`);
            }

            const month = monthNames[getMonth(parseISO(firstWeekDay.date))];
            const prev = labels[labels.length - 1];

            if (index === 0 || prev.text !== month) {
                return [
                    ...labels,
                    {
                        x: index,
                        y: 0,
                        text: month,
                    },
                ];
            }

            return labels;
        }, [])
        .filter((label, index, labels) => {
            if (index === 0) {
                return labels[1] && labels[1].x - label.x > MIN_DISTANCE_MONTH_LABELS;
            }

            return true;
        });
}
Enter fullscreen mode Exit fullscreen mode

The function getMonthLabels takes in list of weeks and monthNames as input parameters. For each week, find the first day and get the monthName for the first day. If the last month in the label is not same or it's first value then x and y is set to 0 else return labels. Filter out the labels for each starting index with greater than minimum distance between the labels or other indexes.

Creating a Calendar Theme

utils.js

...
import getMonth from 'date-fns/getMonth';
import color from 'tinycolor2';

const DEFAULT_THEME = createCalendarTheme('#042a33');
...
export function createCalendarTheme(baseColor, emptyColor = color('white').darken(8).toHslString()) {
    const base = color(baseColor);

    if (!base.isValid()) {
        return DEFAULT_THEME;
    }

    return {
        level4: base.setAlpha(0.44).toHslString(),
        level3: base.setAlpha(0.6).toHslString(),
        level2: base.setAlpha(0.76).toHslString(),
        level1: base.setAlpha(0.92).toHslString(),
        level0: emptyColor,
    };
}
Enter fullscreen mode Exit fullscreen mode

The function createCalendarTheme takes baseColor and emptyColor as input parameters and returns a theme with 5 different colors for each level.

Helper Functions

utils.js

...
export const NAMESPACE = 'ActivityCalendar';

export const DEFAULT_WEEKDAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];

export const DEFAULT_LABELS = {
    months: DEFAULT_MONTH_LABELS,
    weekdays: DEFAULT_WEEKDAY_LABELS,
    totalCount: '{{count}} contributions in {{year}}',
    legend: {
        less: 'Less',
        more: 'More',
    },
};
...

export function getTheme(theme, color) {
    if (theme) {
        return Object.assign({}, DEFAULT_THEME, theme);
    }

    if (color) {
        return createCalendarTheme(color);
    }

    return DEFAULT_THEME;
}

export function getClassName(name, styles) {
    if (styles) {
        return `${NAMESPACE}__${name} ${styles}`;
    }

    return `${NAMESPACE}__${name}`;
}

export function generateEmptyData() {
    const year = new Date().getFullYear();
    const days = eachDayOfInterval({
        start: new Date(year, 0, 1),
        end: new Date(year, 11, 31),
    });

    return days.map((date) => ({
        date: formatISO(date, { representation: 'date' }),
        count: 0,
        level: 0,
    }));
}

Enter fullscreen mode Exit fullscreen mode

Calculating Size

ActivityCalendar.jsx

import React from 'react';

import { groupByWeeks } from './utils';

function ActivityCalendar({
    blockMargin,
    children,
    color,
    data,
    fontSize,
    hideMonthLabels,
    labels: labelsProp,
    style,
    theme: themeProp,
    weekStart,
    ...otherProps
}) {
    if (data.length === 0) return null;

    const weeks = groupByWeeks(data, weekStart);
    const textHeight = hideMonthLabels ? 0 : fontSize + 2 * blockMargin;

    function getDimensions() {
        return {
            width: weeks.length * (blockSize + blockMargin) - blockMargin,
            height: textHeight + (blockSize + blockMargin) * 7 - blockMargin,
        };
    }
...
}

ActivityCalendar.defaultProps = {
    blockMargin: 4,
    blockSize: 12,
    color: undefined,
    fontSize: 14,
    hideMonthLabels: false,
    style: {},
    weekStart: 0,
};
...
Enter fullscreen mode Exit fullscreen mode

Using blockSize, blockMargin, length of weeks array and textHeight, calculate width and height of the Activity Calendar.

Adding Styles Module

styles.module.css

.calendar {
    display: block;
    max-width: 100%;
    height: auto;
    overflow: visible;
}

.calendar text {
    fill: currentColor;
}

.block {
    stroke: rgba(0, 0, 0, 0.1);
    stroke-width: 1px;
    shape-rendering: geometricPrecision;
}

.footer {
    display: flex;
}

.legendColors {
    margin-left: auto;
    display: flex;
    align-items: center;
    gap: 0.2em;
}

@keyframes loadingAnimation {
    0% {
        fill: var(--ActivityCalendar-loading);
    }
    50% {
        fill: var(--ActivityCalendar-loading-active);
    }
    100% {
        fill: var(--ActivityCalendar-loading);
    }
}
Enter fullscreen mode Exit fullscreen mode

Using CSS module, ActivityCalendar uses loadingAnimation and other necessary styles.

Rendering Blocks

ActivityCalendar.jsx

...
import { getClassName, getTheme, groupByWeeks, NAMESPACE } from './utils';

import styles from './styles.module.css';
import tinycolor from 'tinycolor2';

function ActivityCalendar({
    blockMargin,
    blockRadius,
    blockSize,
    children,
    color,
    data,
    dateFormat,
    fontSize,
    hideMonthLabels,
    loading,
    labels: labelsProp,
    style,
    theme: themeProp,
    weekStart,
    ...otherProps
}) {
    if (loading) data = generateEmptyData();
    ...
    const theme = getTheme(themeProp, color);
    ...

    function renderBlocks() {
        return weeks
            .map((week, weekIndex) =>
                week.map((day, dayIndex) => {
                    if (!day) {
                        return null;
                    }

                    const style = loading
                        ? {
                                animation: `${styles.loadingAnimation} 1.5s ease-in-out infinite`,
                                animationDelay: `${weekIndex * 20 + dayIndex * 20}ms`,
                          }
                        : undefined;

                    return (
                        <rect
                            // {...getEventHandlers(day)}
                            x={0}
                            y={textHeight + (blockSize + blockMargin) * dayIndex}
                            width={blockSize}
                            height={blockSize}
                            fill={theme[`level${day.level}`]}
                            rx={blockRadius}
                            ry={blockRadius}
                            className={styles.block}
                            data-date={day.date}
                            key={day.date}
                            style={style}
                        />
                    );
                })
            )
            .map((week, x) => (
                <g key={x} transform={`translate(${(blockSize + blockMargin) * x}, 0)`}>
                    {week}
                </g>
            ));
    }
    const { width, height } = getDimensions();
    const additionalStyles = {
        maxWidth: width,
        // Required for correct colors in CSS loading animation
        [`--${NAMESPACE}-loading`]: theme.level0,
        [`--${NAMESPACE}-loading-active`]: tinycolor(theme.level0).darken(8).toString(),
    };

    return (
        <article className="ActivityCalendar" style={{ ...style, ...additionalStyles }} {...otherProps}>
            <svg
                className={getClassName('calendar', styles.calendar)}
                width={width}
                height={height}
                viewBox={`0 0 ${width} ${height}`}
            >
                {renderBlocks()}
            </svg>
            {children}
        </article>
    );
}
...
Enter fullscreen mode Exit fullscreen mode

The function renderBlocks renders coloured blocks for each day chunked in weeks and coloured according to the theme.

Rendering Labels

ActivityCalendar.jsx

...
import getDay from 'date-fns/getDay';

import { DEFAULT_LABELS, DEFAULT_WEEKDAY_LABELS, generateEmptyData, getClassName, getMonthLabels, getTheme, groupByWeeks, MIN_DISTANCE_MONTH_LABELS, NAMESPACE } from './utils';
...

function ActivityCalendar({
    blockMargin,
    blockRadius,
    blockSize,
    children,
    color,
    data,
    dateFormat,
    fontSize,
    hideMonthLabels,
    loading,
    labels: labelsProp,
    style,
    showWeekdayLabels,
    theme: themeProp,
    weekStart,
    ...otherProps
}) {
    if (loading) data = generateEmptyData();

    if (data.length === 0) return null;

    const weeks = groupByWeeks(data, weekStart);
    const textHeight = hideMonthLabels ? 0 : fontSize + 2 * blockMargin;
    const theme = getTheme(themeProp, color);
    const labels = Object.assign({}, DEFAULT_LABELS, labelsProp);
    ...

    function renderLabels() {
        const style = {
            fontSize,
        };

        if (!showWeekdayLabels && hideMonthLabels) {
            return null;
        }

        return (
            <>
                {showWeekdayLabels && (
                    <g className={getClassName('legend-weekday')} style={style}>
                        {weeks[1].map((day, y) => {
                            if (!day || y % 2 === 0) {
                                return null;
                            }

                            const dayIndex = getDay(parseISO(day.date));

                            return (
                                <text
                                    x={-2 * blockMargin}
                                    y={textHeight + (fontSize / 2 + blockMargin) + (blockSize + blockMargin) * y}
                                    textAnchor="end"
                                    key={day.date}
                                >
                                    {labels.weekdays ? labels.weekdays[dayIndex] : DEFAULT_WEEKDAY_LABELS[dayIndex]}
                                </text>
                            );
                        })}
                    </g>
                )}
                {!hideMonthLabels && (
                    <g className={getClassName('legend-month')} style={style}>
                        {getMonthLabels(weeks, labels.months).map(({ text, x }, index, labels) => {
                            // Skip the first month label if there's not enough space to the next one
                            if (index === 0 && labels[1] && labels[1].x - x <= MIN_DISTANCE_MONTH_LABELS) {
                                return null;
                            }

                            return (
                                <text x={(blockSize + blockMargin) * x} alignmentBaseline="hanging" key={x}>
                                    {text}
                                </text>
                            );
                        })}
                    </g>
                )}
            </>
        );
    }
...

    return (
        <article className="ActivityCalendar" style={{ ...style, ...additionalStyles }} {...otherProps}>
            <svg
                className={getClassName('calendar', styles.calendar)}
                width={width}
                height={height}
                viewBox={`0 0 ${width} ${height}`}
            >
                {!loading && renderLabels()}
                {renderBlocks()}
            </svg>
            {children}
        </article>
    );

}
...

Enter fullscreen mode Exit fullscreen mode

The function renderLabels renders labels for weeks and days based on hideMonthLabels(props) and showWeekdayLabels(props).

Render Footer

ActivityCalendar.jsx

...
import {
    DEFAULT_LABELS,
    DEFAULT_WEEKDAY_LABELS,
    generateEmptyData,
    getClassName,
    getMonthLabels,
    getTheme,
    groupByWeeks,
    MIN_DISTANCE_MONTH_LABELS,
    NAMESPACE,
} from './utils';

...

function ActivityCalendar({
    blockMargin,
    blockRadius,
    blockSize,
    children,
    color,
    data,
    dateFormat,
    fontSize,
    hideColorLegend,
    hideMonthLabels,
    hideTotalCount,
    loading,
    labels: labelsProp,
    style,
    showWeekdayLabels,
    theme: themeProp,
    weekStart,
    ...otherProps
}) {
    ...
    const labels = Object.assign({}, DEFAULT_LABELS, labelsProp);
    const totalCount = data.reduce((sum, day) => sum + day.count, 0);
    const year = getYear(parseISO(data[0].date));
    ....

    function renderFooter() {
        if (hideTotalCount && hideColorLegend) {
            return null;
        }

        return (
            <footer className={getClassName('footer', styles.footer)} style={{ marginTop: 2 * blockMargin, fontSize }}>
                {/* Placeholder */}
                {loading && <div>&nbsp;</div>}

                {!loading && !hideTotalCount && (
                    <div className={getClassName('count')}>
                        {labels.totalCount
                            ? labels.totalCount
                                    .replace('{{count}}', String(totalCount))
                                    .replace('{{year}}', String(year))
                            : `${totalCount} contributions in ${year}`}
                    </div>
                )}

                {!loading && !hideColorLegend && (
                    <div className={getClassName('legend-colors', styles.legendColors)}>
                        <span style={{ marginRight: '0.4em' }}>{labels.legend.less ?? 'Less'}</span>
                        {Array(5)
                            .fill(undefined)
                            .map((_, index) => (
                                <svg width={blockSize} height={blockSize} key={index}>
                                    <rect
                                        width={blockSize}
                                        height={blockSize}
                                        fill={theme[`level${index}`]}
                                        rx={blockRadius}
                                        ry={blockRadius}
                                    />
                                </svg>
                            ))}
                        <span style={{ marginLeft: '0.4em' }}>{labels.legend.more ?? 'More'}</span>
                    </div>
                )}
            </footer>
        );
    }

    ...

    return (
        <article className="ActivityCalendar" style={{ ...style, ...additionalStyles }} {...otherProps}>
            <svg
                className={getClassName('calendar', styles.calendar)}
                width={width}
                height={height}
                viewBox={`0 0 ${width} ${height}`}
            >
                {!loading && renderLabels()}
                {renderBlocks()}
            </svg>
            {renderFooter()}
            {children}
        </article>
    );
}

...
Enter fullscreen mode Exit fullscreen mode

The function renderFooter renders the footer with total no of contributions and showsLegend for colours used in the heatmap graph.

Final Code

ActivityCalendar.jsx

https://github.com/shivishbrahma/nuclear-reactor/blob/main/src/ActivityCalendar/ActivityCalendar.jsx

utils.js

https://github.com/shivishbrahma/nuclear-reactor/blob/main/src/ActivityCalendar/utils.js

styles.module.css

https://github.com/shivishbrahma/nuclear-reactor/blob/main/src/ActivityCalendar/styles.module.css

Use within App component

import ActivityCalendar from './ActivityCalendar';
import './App.css';

const DEFAULT_THEME = {
        level4: '#216e39',
        level3: '#30a14e',
        level2: '#40c463',
        level1: '#9be9a8',
        level0: '#ebedf0',
    },
    calendarData = [
        {
            count: 0,
            date: '2022-01-01',
            level: 0,
        },
        {
            count: 0,
            date: '2022-01-02',
            level: 0,
        }
        ...
    ];
function App() {
    return (
        <div className="App">
            <ActivityCalendar data={calendarData} theme={DEFAULT_THEME}/>
        </div>
    );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Preview

Preview of Activity Calendar Component

Reference

Top comments (0)