UI Elements

Weekly Tasks

A weekly day picker that animates the selected day's letters and reveals upcoming tasks when today is chosen.

weekly-tasks.tsx
"use client";

import { cn } from "@/lib/utils";
import { Calendar } from "lucide-react";
import {
  AnimatePresence,
  MotionConfig,
  Transition,
  motion as m,
} from "motion/react";
import { useState } from "react";

interface DayInfo {
  month: string;
  date: number;
  day: string;
  isToday: boolean;
}

/**
 * Returns the start date (Monday) of the week containing the given date
 * @param date Optional date to calculate from. Defaults to current date if not provided
 * @returns Date object representing Monday of the current week
 */
function getStartOfWeek(date: Date = new Date()): Date {
  const day = date.getDay();
  // Convert Sunday (0) to 7 to make Monday (1) the start of the week
  const diff = date.getDate() - (day === 0 ? 7 : day) + 1;
  const startOfWeek = new Date(date);
  startOfWeek.setDate(diff);
  // Reset time to start of day
  startOfWeek.setHours(0, 0, 0, 0);
  return startOfWeek;
}

/**
 * Returns an array of formatted day information for the week
 * @param date Optional date to calculate from. Defaults to current date if not provided
 * @returns Array of DayInfo objects containing formatted date information
 */
function getWeekDates(date: Date = new Date()): DayInfo[] {
  const startOfWeek = getStartOfWeek(date);
  const weekDates: DayInfo[] = [];
  const today = new Date();
  today.setHours(0, 0, 0, 0);

  const months = [
    "JANUARY",
    "FEBRUARY",
    "MARCH",
    "APRIL",
    "MAY",
    "JUNE",
    "JULY",
    "AUGUST",
    "SEPTEMBER",
    "OCTOBER",
    "NOVEMBER",
    "DECEMBER",
  ];

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

  for (let i = 0; i < 7; i++) {
    const currentDate = new Date(startOfWeek);
    currentDate.setDate(startOfWeek.getDate() + i);

    weekDates.push({
      month: months[currentDate.getMonth()],
      date: currentDate.getDate(),
      day: days[currentDate.getDay()],
      isToday: currentDate.getTime() === today.getTime(),
    });
  }

  return weekDates;
}

const transition: Transition = {
  type: "spring",
  stiffness: 80,
  damping: 10,
  mass: 0.5,
  duration: 0.4,
};

const tasks = [
  {
    id: 1,
    title: "Team meeting",
    description: "Today, 2:00 PM",
  },
  {
    id: 2,
    title: "Project Deadline",
    description: "Tomorrow, 5:00 PM",
  },
];

const letterVariants = {
  hidden: { filter: "blur(3px)", y: 20, opacity: 0, scale: 0.5 },
  visible: { filter: "blur(0px)", y: 0, opacity: 1, scale: 1 },
  exit: { filter: "blur(3px)", y: -20, opacity: 0, scale: 0.5 },
};

const taskContainerVariants = {
  hidden: { opacity: 0, y: 20 },
  visible: { opacity: 1, y: 0 },
  exit: { opacity: 0, y: 20 },
};

const Tasks = () => {
  const weekDates = getWeekDates();
  const [selected, setSelected] = useState<DayInfo>(
    () => weekDates.find((date) => date.isToday) || weekDates[0]
  );

  return (
    <MotionConfig transition={transition}>
      <div className="full center">
        <m.div
          layout
          className="border border-foreground/10 rounded-3xl p-6 bg-muted flex flex-col gap-4 relative overflow-hidden"
          transition={{
            layout: {
              type: "spring",
              stiffness: 80,
              damping: 12,
            },
          }}
        >
          <AnimatePresence mode="popLayout">
            <m.div
              key={`${selected.day}-${selected.date}`}
              className="text-3xl font-bold"
              initial="hidden"
              animate="visible"
              exit="exit"
            >
              {selected.day.split("").map((letter, index) => (
                <m.span
                  key={`${selected.day}-${index}`}
                  variants={letterVariants}
                  initial="hidden"
                  animate="visible"
                  exit="exit"
                  transition={{
                    ...transition,
                    delay: index * 0.05,
                  }}
                  className="inline-block"
                >
                  {letter}
                </m.span>
              ))}
            </m.div>
          </AnimatePresence>

          <AnimatePresence mode="popLayout">
            {selected.isToday && (
              <m.div
                className="flex flex-col gap-4"
                variants={taskContainerVariants}
                initial="hidden"
                animate="visible"
                exit="exit"
                transition={{
                  ...transition,
                  staggerChildren: 0.05,
                }}
                layout
              >
                <m.p
                  className="flex items-center gap-2"
                  variants={taskContainerVariants}
                >
                  <Calendar className="size-4" />
                  <span>Upcoming tasks</span>
                </m.p>
                <m.ul className="flex gap-2">
                  {tasks.map((task, index) => (
                    <m.li
                      key={task.id}
                      className="flex flex-col border border-muted-foreground/10  hover:bg-muted-foreground/10 cursor-pointer flex-1 rounded-xl py-2 px-4"
                      variants={taskContainerVariants}
                      transition={{
                        ...transition,
                        delay: index * 0.1,
                      }}
                    >
                      <p className="font-medium">{task.title}</p>
                      <p className="text-primary/50 text-sm">
                        {task.description}
                      </p>
                    </m.li>
                  ))}
                </m.ul>
              </m.div>
            )}
          </AnimatePresence>

          <div className="flex gap-4 bg-muted z-20">
            {weekDates.map((date) => (
              <m.div
                key={date.date}
                layoutId={`day-${date.date}`}
                className={cn(
                  "flex flex-col items-center hover:bg-muted-foreground/10 rounded-xl p-2 cursor-pointer text-primary/70 relative",
                  selected?.date === date.date &&
                    "bg-background text-primary border hover:bg-background/80"
                )}
                onClick={() => setSelected(date)}
                whileHover={{ scale: 1.03 }}
                whileTap={{ scale: 0.97 }}
              >
                <h2>{date.month}</h2>
                <p className="font-bold">{date.date}</p>
                <p>{date.day}</p>
                {selected?.date === date.date && (
                  <m.div
                    className="absolute size-2 bg-red-500 -top-1 rounded-xl bg-background"
                    layoutId="day-selected-active"
                  />
                )}
              </m.div>
            ))}
          </div>
        </m.div>
      </div>
    </MotionConfig>
  );
};

export default Tasks;