Building a Sunsama Clone: Part 2 – Daily Planning & Time Blocking

Written by:

The one where I learn that dates are hard, positioning is harder, and sometimes you need 6 tries to get something pixel-perfect

Part 2 of an ongoing series documenting my journey building a production-ready task management app. Read Part 1 here for the foundation and setup.


After getting basic CRUD working in Part 1, I dove into what actually makes Sunsama worth using: visual time blocking. Turns out this was way more involved than I thought and took multiple sessions to get right.

What I Was Going For

I wanted to replicate the Sunsama workflow:

  1. Separate “Today” view for tasks I’m actually doing today
  2. Time estimates on tasks
  3. Drag tasks into specific time slots
  4. Actually see my day blocked out visually

Should be straightforward, right? Hah.

Session 2: Getting Planning to Work

Database Updates

First I needed to track when tasks are planned and how long they take. Updated the schema:

model Task {
  id           String    @id @default(uuid())
  title        String
  description  String?
  completed    Boolean   @default(false)
  dueDate      DateTime?
  plannedDate  DateTime?    // When am I doing this?
  timeEstimate Int?         // How long will it take?
  createdAt    DateTime  @default(now())
  updatedAt    DateTime  @updatedAt
  userId       String
  user         User      @relation(...)
}

Ran the migration. Easy enough.

Today View

Built a new page that filters to only show tasks planned for today. The filtering seems obvious but I quickly ran into a timezone bug that would’ve been super annoying in production.

My first try:

const isToday = (dateString: string | null) => {
  if (!dateString) return false;
  return new Date(dateString).toDateString() === new Date().toDateString();
};

This broke because dates from the database are UTC, but toDateString() uses local time. A task “planned for today” in UTC could show as yesterday in my timezone. Great.

Fixed it by comparing date components directly:

const isToday = (dateString: string | null) => {
  if (!dateString) return false;
  const taskDate = new Date(dateString);
  const today = new Date();

  return (
    taskDate.getFullYear() === today.getFullYear() &&
    taskDate.getMonth() === today.getMonth() &&
    taskDate.getDate() === today.getDate()
  );
};

Time Estimates

Added a number input to the task form:

<input
  type="number"
  value={timeEstimate || ''}
  onChange={(e) => setTimeEstimate(e.target.value ? parseInt(e.target.value) : undefined)}
  placeholder="e.g., 30"
  min="5"
  step="5"
/>

Basic but functional.

Daily Summary

Built a card showing:

  • Total time planned
  • Task count
  • Percentage of day allocated
export function DailyTimeSummary({ tasks }) {
  const totalMinutes = tasks.reduce((sum, task) => sum + (task.timeEstimate || 0), 0);
  const hours = Math.floor(totalMinutes / 60);
  const minutes = totalMinutes % 60;

  return (
    <div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
      <h2>Daily Summary</h2>
      <p>Total Time: {hours}h {minutes}m</p>
      <p>Tasks: {tasks.length}</p>
      <p>Day Planned: {Math.round(totalMinutes / 480 * 100)}%</p>
    </div>
  );
}

The 480 is an 8-hour workday in minutes. I’ll make this customizable later because I don’t always work 9-5.

Plan for Today Button

Added a button in All Tasks to move tasks to today:

const handlePlanForToday = (id: string) => {
  const today = new Date();
  today.setHours(0, 0, 0, 0);
  planMutation.mutate({ id, plannedDate: today.toISOString() });
};

Edit Functionality

At this point I cant’t edit tasks after creating them. Built an edit modal that pre-populates a form with existing values:

const [title, setTitle] = useState(task.title);
const [description, setDescription] = useState(task.description || '');
const [dueDate, setDueDate] = useState(
  task.dueDate ? new Date(task.dueDate).toISOString().split('T')[0] : ''
);

Standard form stuff.

Navigation

Added tabs to switch views:

const [view, setView] = useState<'today' | 'tasks'>('today');

<button onClick={() => setView('today')}>Today</button>
<button onClick={() => setView('tasks')}>All Tasks</button>

At this point I can plan tasks for today and see them separately. But they were still just a list. Time for the hard part.

Sessions 3-4: The Fun Part (Visual Time Blocking)

This is where things got interesting. By interesting I mean I spent way too much time on CSS positioning.

Adding Start Time

Added a field to track when tasks are scheduled:

model Task {
  // ... existing fields
  startTime    String?      // "09:00", "14:30", etc.
}

Timeline Component

Built a timeline showing hourly slots:

export function Timeline({ tasks }) {
  const hours = Array.from({ length: 10 }, (_, i) => i + 8); // 8 AM - 5 PM

  return (
    <div>
      {hours.map(hour => (
        <TimeSlot key={hour} hour={hour} />
      ))}
    </div>
  );
}

Each TimeSlot is just a div with the hour label and a drop zone.

Drag and Drop

Installed @dnd-kit:

npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities

Made tasks draggable:

function DraggableTaskItem({ task }) {
  const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
    id: task.id,
    data: { task }
  });

  return (
    <div ref={setNodeRef} {...listeners} {...attributes}>
      {/* Task content */}
    </div>
  );
}

Made timeline slots droppable:

function TimeSlot({ hour }) {
  const { setNodeRef, isOver } = useDroppable({
    id: `timeslot-${hour}:00`,
    data: { hour, timeSlot: `${hour}:00` }
  });

  return (
    <div 
      ref={setNodeRef} 
      className={isOver ? 'bg-blue-50' : ''}
    >
      {/* Drop zone */}
    </div>
  );
}

Wired up the drop handler:

const handleDragEnd = (event) => {
  const { active, over } = event;
  if (!over) return;

  const taskId = active.id.toString();
  const timeSlot = over.data?.current?.timeSlot;

  updateMutation.mutate({ id: taskId, data: { startTime: timeSlot } });
};

And boom – I can now drag tasks into time slots. But they all looked identical in size, which defeated the whole point of visual time blocking.

Visual Task Scaling (Where I Lost Hours (I’m Exaggerating) of My Life)

Goal: make tasks scale based on time estimate. A 15-minute task should look tiny compared to a 2-hour meeting.

Approach: absolute positioning with calculated heights.

function ScheduledTask({ task, startHour }) {
  const [hourStr, minuteStr] = task.startTime.split(':');
  const taskStartHour = parseInt(hourStr);
  const startMinute = parseInt(minuteStr);

  const hoursSinceStart = taskStartHour - startHour;
  const hourHeight = 64; // Each hour slot is 64px

  // Position calculation
  const topPosition = (hoursSinceStart * hourHeight) + (startMinute / 60 * hourHeight);

  // Height calculation
  const totalMinutes = task.timeEstimate || 0;
  const calculatedHeight = (totalMinutes / 60) * hourHeight;

  // Minimums for readability
  const height = totalMinutes <= 15 
    ? Math.max(calculatedHeight, 28)
    : Math.max(calculatedHeight, 40);

  return (
    <div 
      style={{ 
        position: 'absolute',
        top: `${topPosition}px`,
        height: `${height}px`
      }}
    >
      {task.title}
    </div>
  );
}

This. Took. Forever. Here’s what went wrong:

Problem 1: Tasks Weren’t Aligning

Tasks kept appearing slightly above where they should be. I spent an embarrassing amount of time debugging this. I realized I was mixing rendering approaches. Some tasks were rendered inline, while others were absolutely positioned. Once I committed to pure absolute positioning, it worked.

Problem 2: Tiny Tasks Were Unreadable

A 15-minute task at 16px tall is basically invisible. Added minimum heights and adaptive styling:

const isTiny = height < 45;

const padding = isTiny ? 'p-1' : 'p-2';
const textSize = isTiny ? 'text-xs' : 'text-sm';

Problem 3: 15-Minute and 30-Minute Tasks Looked Identical

Both hit the same minimum. Fixed with duration-based minimums:

const height = totalMinutes <= 15 
  ? Math.max(calculatedHeight, 28)  // Tiny
  : Math.max(calculatedHeight, 40); // Normal

Problem 4: Scaling Was Wrong

Tried multiple approaches before finding what worked:

  • No scaling → too small, unreadable
  • 2x multiplier → 60-min tasks taking up 2 hours, completely wrong
  • 1.5x multiplier → still oversized
  • 1.1x multiplier → close but still off
  • No multiplier, just smart minimums → finally correct

Lesson learned: sometimes the simplest solution is right.

Multi-Hour Spanning

Long tasks needed to flow across multiple hours instead of being confined to one. The math:

function getTaskSpan(task) {
  if (!task.startTime || !task.timeEstimate) return null;

  const [hourStr, minuteStr] = task.startTime.split(':');
  const startHour = parseInt(hourStr);
  const startMinute = parseInt(minuteStr);
  const totalMinutes = task.timeEstimate;

  return { startHour, startMinute, totalMinutes };
}

Tasks now overlay multiple slots seamlessly.

The Button Problem

Made everything draggable. Immediately broke all the buttons – checkboxes, edit, delete. Because the drag was capturing everything.

Fix: drag handle. Only the handle triggers dragging:

<div className="flex items-start gap-3">
  {/* ONLY this part is draggable */}
  <div {...listeners} {...attributes} className="cursor-move">
    <svg>
      <path d="M4 8h16M4 16h16" />
    </svg>
  </div>

  {/* These work fine now */}
  <input type="checkbox" onChange={() => onToggle(task.id)} />
  <button onClick={() => onEdit(task)}>Edit</button>
</div>

Problem solved.

Session 5: Making It Not Suck

Core functionality worked. Now for the polish that makes it actually usable.

Conflict Detection

Added overlap detection:

function tasksOverlap(task1, task2) {
  if (!task1.startTime || !task2.startTime) return false;

  const [h1, m1] = task1.startTime.split(':').map(Number);
  const start1 = h1 * 60 + m1;
  const end1 = start1 + (task1.timeEstimate || 0);

  const [h2, m2] = task2.startTime.split(':').map(Number);
  const start2 = h2 * 60 + m2;
  const end2 = start2 + (task2.timeEstimate || 0);

  return start1 < end2 && start2 < end1;
}

Overlapping tasks get orange styling with a warning. Simple but effective – you immediately know when you’ve double-booked yourself.

Current Time Indicator

Red line showing where I am in my day:

export function CurrentTimeIndicator({ startHour, endHour }) {
  const [currentTime, setCurrentTime] = useState(new Date());

  useEffect(() => {
    const interval = setInterval(() => {
      setCurrentTime(new Date());
    }, 60000);
    return () => clearInterval(interval);
  }, []);

  const hour = currentTime.getHours();
  if (hour < startHour || hour >= endHour) return null;

  const minutes = currentTime.getMinutes();
  const hoursSinceStart = hour - startHour;
  const percentageFromTop = ((hoursSinceStart + minutes / 60) / (endHour - startHour)) * 100;

  return (
    <div style={{ top: `${percentageFromTop}%` }}>
      <span>{currentTime.toLocaleTimeString()}</span>
      <div className="bg-red-500 h-0.5"></div>
    </div>
  );
}

Updates every minute. Actually pretty useful for staying on track.

Keyboard Shortcuts

I hate using the mouse for everything. Built a hook for keyboard shortcuts:

export function useKeyboardShortcut(key, callback) {
  useEffect(() => {
    const handleKeyDown = (event) => {
      const target = event.target;

      // Don't trigger while typing
      if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
        return;
      }

      if (event.key.toLowerCase() === key.toLowerCase()) {
        event.preventDefault();
        callback();
      }
    };

    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, [key, callback]);
}

Added the shortcuts I actually want:

useKeyboardShortcut('n', () => setShowCreateModal(true));
useKeyboardShortcut('t', () => setView('today'));
useKeyboardShortcut('a', () => setView('tasks'));
useKeyboardShortcut('Escape', () => setShowCreateModal(false));

Also added visual hints:

<button>
  Today <kbd className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">T</kbd>
</button>

Makes the whole app feel faster.

Customizable Timeline Hours

Timeline was hardcoded to 8 AM – 6 PM. I don’t always work those hours. Used Zustand to fix it:

import { create } from 'zustand';
import { persist } from 'zustand/middleware';

export const useSettingsStore = create(
  persist(
    (set) => ({
      startHour: 8,
      endHour: 18,
      setStartHour: (hour) => set({ startHour: hour }),
      setEndHour: (hour) => set({ endHour: hour }),
    }),
    { name: 'timeline-settings' }
  )
);

The persist middleware handles localStorage automatically. No extra work.

Built a settings modal with dropdowns for start/end hours. Updated timeline to use the custom hours:

const { startHour, endHour } = useSettingsStore();
const hours = Array.from({ length: endHour - startHour }, (_, i) => i + startHour);

Everything adapts – positioning, current time indicator, percentage calculations. Works perfectly.

Scheduled vs Unscheduled Time

Updated the summary to show the breakdown:

const scheduledTasks = tasks.filter(task => task.startTime);
const scheduledMinutes = scheduledTasks.reduce((sum, t) => sum + (t.timeEstimate || 0), 0);

const unscheduledTasks = tasks.filter(task => !task.startTime);
const unscheduledMinutes = unscheduledTasks.reduce((sum, t) => sum + (t.timeEstimate || 0), 0);

Now I see:

  • Total time (everything)
  • Scheduled (green) – actually blocked out
  • Unscheduled (orange) – still floating

Helps distinguish between “planned” and “organized.”

The Past Task Bug

Found an annoying bug: tasks from yesterday disappeared from Today but had no way to reschedule them. They just sat in All Tasks with no action available.

Fixed it:

const isPlannedForPast = task.plannedDate && 
  new Date(task.plannedDate).toDateString() !== new Date().toDateString();

{(!task.plannedDate || isPlannedForPast) && (
  <button onClick={() => onPlanForToday(task.id)}>
    {isPlannedForPast ? 'Reschedule for Today' : 'Plan for Today'}
  </button>
)}

Now old tasks show “Reschedule for Today” instead of being orphaned.

What I Actually Learned

CSS Positioning is a Pain (I always knew this)

Getting task positioning pixel-perfect took way more attempts than I care to admit. The key insight was committing to pure absolute positioning instead of mixing approaches.

Test With Real Data

The timezone bug and past task bug only showed up with actual daily use. Synthetic tests wouldn’t have caught them.

Small Details Matter

Things like drag handles, overlap warnings, and keyboard shortcuts aren’t technically complex. But they’re the difference between “works” and “actually want to use this.”

Don’t Overthink Scaling

I tried multiple complex multipliers for task heights before realizing simple minimums worked best. Sometimes less is more.

Zustand > Redux for This

For simple state like settings, Zustand with persistence is perfect. Way less ceremony than Redux.

Current State

Working features:

  • ✅ Task CRUD
  • ✅ Daily planning
  • ✅ Visual time blocking
  • ✅ Drag & drop
  • ✅ Task duration scaling
  • ✅ Multi-hour spanning
  • ✅ Conflict detection
  • ✅ Current time indicator
  • ✅ Keyboard shortcuts
  • ✅ Custom work hours
  • ✅ Smart rescheduling

It’s actually usable. I’ve been lightly planning my days with it and it works.

What’s Next

Considering:

  • Pomodoro timer
  • Week view
  • User auth (goodbye temp user)
  • Task notes/subtasks
  • Google Calendar sync
  • Mobile app

But I’m going to use this for a week first and see what I actually need vs what sounds cool.

Stats

  • Sessions: 5
  • Files: ~15 components
  • Lines: ~1500+
  • Hours: ~10-12
  • Bugs: 4 major
  • Features: 15+


The hardest parts weren’t the complex features. They were the edge cases and UX details you only find by using what you build.

Next up: probably Pomodoro timer. I keep wanting to track actual time vs estimates.

Leave a comment