UI Elements

Cashflow Chart

A monthly cashflow heatmap calendar with income/expense bars per day, hover tooltips, and an animated segmented view toggle.

cashflow-chart.tsx
"use client";

import React, { createContext, ReactNode, useContext, useState } from "react";

import { cn } from "@/lib/utils";
import { ArrowDownLeft, ArrowUpRight } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";

interface DayProps {
  classNames: string;
  day: DayType;
  details?: {
    income: number;
    expenses: number;
  };
}

const Day: React.FC<DayProps> = ({ classNames, details, day }) => {
  const [isHovered, setIsHovered] = useState(false);
  const { setView, setHoveredDay, view, hoveredDay, currentMonth } =
    useCashflow();

  const currentMonthDate = new Date(currentMonth);
  const currentMonthYear = currentMonthDate.getFullYear();
  const hoveredDayDate = hoveredDay
    ? new Date(
        `${currentMonthYear}-${currentMonthDate.getMonth() + 1}-${
          hoveredDay.day
        }`,
      )
    : null;

  return (
    <>
      <div
        className={cn(
          "relative flex items-center justify-center py-1 duration-300 transition-opacity",
          classNames,
        )}
        style={{
          height: 80,
          borderRadius: 10,
        }}
        onMouseEnter={() => {
          setHoveredDay(day.details ? day : null);
          setIsHovered(true);
        }}
        onMouseLeave={() => {
          setHoveredDay(null);
          setIsHovered(false);
        }}
        id={`day-${day.day}`}
      >
        <motion.div className="flex flex-col items-center justify-center z-10 ">
          <span
            className={cn(
              "text-lg block mt-1 font-semibold transition-all duration-300",
            )}
            style={{
              fontSize: day.details ? "1.2rem" : "1rem",
            }}
          >
            {day.day}
          </span>
        </motion.div>
        {details && (
          <div
            className="flex flex-col items-center justify-center absolute inset-0 w-full overflow-hidden h-full"
            style={{
              borderRadius: 10,
            }}
          >
            {details.income > 0 && (
              <div
                className={cn(
                  "bg-muted w-full flex justify-center items-start pt-0.5 transition-all duration-300 rounded-t-lg",
                  isHovered && "bg-green-100 text-green-400",
                )}
                style={{
                  height: `${(details.income / 1000) * 100}%`,
                  color: isHovered ? "#4ade80" : "",
                  background: isHovered ? "#dcfce7" : "",
                  opacity:
                    view === "all" || view === "income" || isHovered ? 1 : 0,
                }}
              >
                <span className="text-sm font-medium w-full text-center block">
                  ${formatNumber(details.income)}
                </span>
              </div>
            )}
            {details.expenses > 0 && (
              <div
                className={cn(
                  "bg-muted-foreground w-full flex justify-center items-end pb-0.5  transition-all duration-300",
                )}
                style={{
                  height: `${(details.expenses / 1000) * 100}%`,
                  color: isHovered ? "#f87171" : "",
                  background: isHovered ? "#fee2e2" : "gray",
                  opacity:
                    view === "all" || view === "expenses" || isHovered ? 1 : 0,
                }}
              >
                <span className="text-sm font-medium w-full text-center block">
                  ${formatNumber(details.expenses)}
                </span>
              </div>
            )}
          </div>
        )}
        <AnimatePresence>
          {details && isHovered && (
            <motion.div
              className="absolute w-[250px] overflow-y-auto z-50"
              style={{
                bottom: 100,
              }}
              initial={{ scale: 0, y: 10, opacity: 0 }}
              animate={{ scale: 1, y: 0, opacity: 1 }}
              exit={{ scale: 0, y: 10, opacity: 0 }}
            >
              <div
                className="w-full mb-1 p-1 bg-background text-foreground border border-muted rounded-xl font-medium flex items-center justify-center"
                style={{
                  borderRadius: 20,
                }}
              >
                <div className="w-full p-3 rounded-2xl overflow-y-auto">
                  <span className="block mb-1.5 text-muted-foreground">
                    {hoveredDayDate
                      ? hoveredDayDate.toLocaleString("en-US", {
                          month: "long",
                          day: "numeric",
                          year: "numeric",
                        })
                      : ""}
                  </span>
                  <div className="w-full flex flex-col">
                    <div
                      className="w-full flex items-center justify-between gap-2"
                      onClick={() => {
                        setView("income");
                        setIsHovered(false);
                      }}
                    >
                      <span>Income</span>
                      <span
                        className="text-green-400 font-semibold"
                        style={{
                          color: "#4ade80",
                        }}
                      >
                        ${details.income.toFixed(2)}
                      </span>
                    </div>
                    <div
                      className="w-full flex items-center justify-between gap-2"
                      onClick={() => {
                        setView("expenses");
                        setIsHovered(false);
                      }}
                    >
                      <span>Expenses</span>
                      <span
                        className="text-red-400 font-semibold"
                        style={{
                          color: "#f87171",
                        }}
                      >
                        ${details.expenses.toFixed(2)}
                      </span>
                    </div>
                  </div>
                  <div className="h-[1px] w-full my-2"></div>
                  <div className="w-full flex items-center justify-between">
                    <span className="text-lg font-semibold">Net Total</span>
                    <span className="flex items-center gap-1 text-lg">
                      {details.income - details.expenses > 0 ? (
                        <ArrowUpRight
                          size={22}
                          style={{
                            color: "#4ade80",
                          }}
                        />
                      ) : (
                        <ArrowDownLeft
                          size={22}
                          style={{
                            color: "#f87171",
                          }}
                        />
                      )}
                      ${(details.income - details.expenses).toFixed(2)}
                    </span>
                  </div>
                </div>
              </div>
            </motion.div>
          )}
        </AnimatePresence>
      </div>
    </>
  );
};

const CalendarGrid: React.FC<{
  currentMonth: Date;
  DAYS: DayType[];
  view: "all" | "income" | "expenses";
}> = ({ currentMonth, DAYS }) => {
  const getDaysInMonth = (date: Date) => {
    const year = date.getFullYear();
    const month = date.getMonth();
    const daysInMonth = new Date(year, month + 1, 0).getDate();
    const firstDayOfMonth = new Date(year, month, 1).getDay();

    const days: DayType[] = [];

    const prevMonth = new Date(year, month, 0);
    const prevMonthDays = prevMonth.getDate();
    for (let i = 0; i < firstDayOfMonth; i++) {
      const day = prevMonthDays - firstDayOfMonth + i + 1;
      days.push({
        day: day.toString(),
        classNames: "opacity-50",
      });
    }

    for (let i = 1; i <= daysInMonth; i++) {
      const dayString = i.toString().padStart(2, "0");
      const existingDay = DAYS.find((d) => d.day === dayString);
      days.push(existingDay || { day: dayString, classNames: "" });
    }

    const remainingDays = 35 - days.length;
    for (let i = 1; i <= remainingDays; i++) {
      days.push({ day: i.toString(), classNames: "bg-transparent opacity-50" });
    }

    return days;
  };

  const monthDays = getDaysInMonth(currentMonth);

  return (
    <div className="grid grid-cols-7 gap-2">
      {monthDays.map((day, index) => (
        <Day
          key={`${day.day}-${index}`}
          classNames={day.classNames}
          details={day.details}
          day={day}
        />
      ))}
    </div>
  );
};

const CashflowHeatmap = () => {
  const { currentMonth, direction, view, setView } = useCashflow();
  const [dir, setDir] = useState(1);

  const VIEWS: {
    key: "all" | "income" | "expenses";
    label: string;
    title: string;
  }[] = [
    { key: "all", label: "All", title: "Cashflow" },
    { key: "income", label: "Income", title: "Income" },
    { key: "expenses", label: "Expenses", title: "Expenses" },
  ];
  const titles = {
    all: "Cashflow",
    income: "Income",
    expenses: "Expenses",
  };

  const titleVariants = {
    initial: (dir: number) => ({
      opacity: 0,
      y: dir === 1 ? 20 : -20,
      filter: "blur(4px)",
      scale: 0.5,
    }),
    animate: { opacity: 1, y: 0, filter: "blur(0px)", scale: 1 },
    exit: (dir: number) => ({
      opacity: 0,
      y: dir === 1 ? -20 : 20,
      filter: "blur(4px)",
      scale: 0.5,
    }),
  };

  const toggleView = (
    newView: "all" | "income" | "expenses",
    newIndex: number,
  ) => {
    const currentIndex = VIEWS.findIndex((v) => v.key === view);

    if (currentIndex !== -1) {
      const direction = newIndex > currentIndex ? 1 : -1;
      setDir(direction);
    }

    setView(newView);
  };

  const monthName = currentMonth.toLocaleString("en-US", { month: "long" });
  const year = currentMonth.getFullYear();

  return (
    <div className="size-full flex items-center justify-center">
      <div
        className="relative p-4 w-full border rounded-2xl bg-background"
        style={{
          maxWidth: 672,
        }}
      >
        <motion.div layout>
          <motion.div
            initial={{ opacity: 0.5 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0.5 }}
            transition={{ duration: 0.15 }}
            className="w-full flex-col flex gap-4"
          >
            <div className="w-full flex justify-between items-center">
              <motion.h1
                // layout
                className="text-xl font-semibold overflow-hidden"
              >
                <AnimatePresence mode="popLayout" initial={false}>
                  <motion.span
                    variants={titleVariants}
                    custom={dir}
                    className="relative"
                    initial="initial"
                    animate="animate"
                    exit="exit"
                    key={view}
                  >
                    {titles[view]}
                  </motion.span>
                </AnimatePresence>{" "}
                Heatmap
              </motion.h1>
              <div className="flex items-center bg-muted p-1 rounded-lg">
                {VIEWS.map((v, index) => (
                  <button
                    key={v.key}
                    className="py-1 text-sm font-semibold px-4 rounded-md relative"
                    onClick={() => toggleView(v.key, index)}
                  >
                    <span
                      className={cn(
                        "z-10 relative duration-300 transition-colors text-muted-foreground",
                      )}
                    >
                      {v.label}
                    </span>
                    {view === v.key && (
                      <motion.div
                        className="absolute bg-background inset-0 w-full h-full "
                        style={{ borderRadius: 8 }}
                        layoutId="indicator"
                      ></motion.div>
                    )}
                  </button>
                ))}
              </div>
            </div>
            <div className="grid grid-cols-7 gap-2">
              {daysOfWeek.map((day) => (
                <div
                  key={day}
                  className="text-xs text-muted-foreground text-center py-1 px-0/5 rounded-xl"
                >
                  {day[0]}
                </div>
              ))}
            </div>
            <AnimatePresence mode="popLayout" initial={false}>
              <motion.div
                key={`${monthName}-${year}-grid`}
                initial={{ x: direction * 50, opacity: 0 }}
                animate={{ x: 0, opacity: 1 }}
                exit={{ x: direction * -50, opacity: 0 }}
                transition={{ duration: 0.3, ease: "easeInOut" }}
                className="z-20"
              >
                <CalendarGrid
                  currentMonth={currentMonth}
                  DAYS={DAYS}
                  view={view}
                />
              </motion.div>
            </AnimatePresence>
          </motion.div>
        </motion.div>
      </div>
    </div>
  );
};

const formatNumber = (num: number): string => {
  if (num < 1000) {
    return num.toFixed(1);
  }

  const formatted = (num / 1000).toFixed(1);

  const final = formatted.endsWith(".0") ? formatted.slice(0, -2) : formatted;

  return `${final}K`;
};

const DAYS: DayType[] = [
  { day: "01", classNames: "bg-transparent" },
  {
    day: "02",
    classNames: "cursor-pointer",
    details: {
      income: 1893.99,
      expenses: 1234.48,
    },
  },
  { day: "03", classNames: "bg-transparent" },
  { day: "04", classNames: "bg-transparent" },
  { day: "05", classNames: "bg-transparent" },
  { day: "06", classNames: "bg-transparent" },
  {
    day: "07",
    classNames: "cursor-pointer",
    details: {
      income: 2378.23,
      expenses: 1076.58,
    },
  },
  { day: "08", classNames: "bg-transparent" },
  { day: "09", classNames: "bg-transparent" },
  { day: "10", classNames: "bg-transparent" },
  {
    day: "11",
    classNames: "cursor-pointer",
    details: {
      income: 0,
      expenses: 1234.48,
    },
  },
  {
    day: "12",
    classNames: "cursor-pointer",
    details: {
      income: 1893.99,
      expenses: 345.67,
    },
  },
  { day: "13", classNames: "bg-transparent" },
  { day: "14", classNames: "bg-transparent" },
  {
    day: "15",
    classNames: "cursor-pointer",
    details: {
      income: 2378.23,
      expenses: 2456.78,
    },
  },
  { day: "16", classNames: "bg-transparent" },
  { day: "17", classNames: "bg-transparent" },
  { day: "18", classNames: "bg-transparent" },
  { day: "19", classNames: "bg-transparent" },
  { day: "20", classNames: "bg-transparent" },
  { day: "21", classNames: "bg-transparent" },
  { day: "22", classNames: "bg-transparent" },
  { day: "23", classNames: "bg-transparent" },
  {
    day: "24",
    classNames: "cursor-pointer",
    details: {
      income: 678.23,
      expenses: 1234.48,
    },
  },
  { day: "25", classNames: "bg-transparent" },
  { day: "26", classNames: "bg-transparent" },
  {
    day: "27",
    classNames: "cursor-pointer",
    details: {
      income: 2378.23,
      expenses: 0,
    },
  },
  { day: "28", classNames: "bg-transparent" },
  { day: "29", classNames: "bg-transparent" },
  {
    day: "30",
    classNames: "cursor-pointer",
    details: {
      income: 890.23,
      expenses: 1234.48,
    },
  },
];

const daysOfWeek = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];

interface DayType {
  day: string;
  classNames: string;
  details?: {
    income: number;
    expenses: number;
  };
}

interface CashflowContextProps {
  currentMonth: Date;
  setCurrentMonth: (month: Date) => void;
  direction: number;
  setDirection: (direction: number) => void;
  view: "all" | "income" | "expenses";
  setView: (view: "all" | "income" | "expenses") => void;
  hoveredDay: DayType | null;
  setHoveredDay: (day: DayType | null) => void;
}

const CashflowContext = createContext<CashflowContextProps | undefined>(
  undefined,
);

const CashflowProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
  const [currentMonth, setCurrentMonth] = useState(new Date());
  const [direction, setDirection] = useState(0);
  const [view, setView] = useState<"all" | "income" | "expenses">("all");
  const [hoveredDay, setHoveredDay] = useState<DayType | null>(null);

  return (
    <CashflowContext.Provider
      value={{
        currentMonth,
        setCurrentMonth,
        direction,
        setDirection,
        view,
        setView,
        hoveredDay,
        setHoveredDay,
      }}
    >
      {children}
    </CashflowContext.Provider>
  );
};

const Cashflow = () => {
  return (
    <CashflowProvider>
      <CashflowHeatmap />
    </CashflowProvider>
  );
};

export default Cashflow;

const useCashflow = () => {
  const context = useContext(CashflowContext);
  if (!context) {
    throw new Error("useCashflow must be used within a CashflowProvider");
  }
  return context;
};