Building a Sunsama Clone, Part 4: Week View & The Recurring Task Saga

Written by:

Previously: I built a daily planning view with drag-and-drop scheduling and a Pomodoro timer. Could I only see one day at a time? Yes. Was that incredibly limiting? Also yes.

The Problem: One Day at a Time Like a Monk

Having a working daily view is cool and all, but planning your life one day at a time is like trying to navigate with a map that only shows your current block. Sure, you know what’s happening today, but what about tomorrow? Or Friday when you have that thing?

I needed to see the whole week. Plan Monday while looking at what’s coming Tuesday. Move tasks around without constantly switching views like some kind of productivity peasant.

Week view was next.

Building the Grid

A week view is basically just seven “Today” views side by side, right? How hard could it be?

Narrator: It was exactly that straightforward, which was suspicious.

Started with a simple 7-column grid using Tailwind:

<div className="grid grid-cols-7 gap-4">
  {weekDates.map((date, i) => (
    <div key={i} className="border rounded-lg p-3">
      {/* Day header */}
      <div className="text-center">
        <div className="text-xs">{date.toLocaleDateString('en-US', { weekday: 'short' })}</div>
        <div className="text-lg font-bold">{date.getDate()}</div>
      </div>
      
      {/* Tasks for this day */}
      <div className="space-y-2">
        {getTasksForDay(date).map(task => (
          <TaskCard key={task.id} task={task} />
        ))}
      </div>
    </div>
  ))}
</div>

Had to write a utility to get the week’s dates starting from Monday (because I’m not a monster who thinks weeks start on Sunday):

export function getWeekDates(date: Date = new Date()): Date[] {
  const dates: Date[] = [];
  const current = new Date(date);
  
  // Get to Monday
  const day = current.getDay();
  const diff = day === 0 ? -6 : 1 - day; // If Sunday, go back 6 days
  current.setDate(current.getDate() + diff);
  
  // Get Monday through Sunday
  for (let i = 0; i < 7; i++) {
    dates.push(new Date(current));
    current.setDate(current.getDate() + 1);
  }
  
  return dates;
}

Added navigation buttons (Previous Week, Today, Next Week) and boom! I could see my whole week.

Then I added keyboard navigation because clicking buttons is for people who have time to waste:

useEffect(() => {
  const handleKeyPress = (e: KeyboardEvent) => {
    if (e.key === 'ArrowLeft') {
      // Go back a week
      const prev = new Date(currentWeek);
      prev.setDate(prev.getDate() - 7);
      setCurrentWeek(prev);
    } else if (e.key === 'ArrowRight') {
      // Go forward a week
      const next = new Date(currentWeek);
      next.setDate(next.getDate() + 7);
      setCurrentWeek(next);
    }
  };
  
  window.addEventListener('keydown', handleKeyPress);
  return () => window.removeEventListener('keydown', handleKeyPress);
}, [currentWeek]);

Left/right arrows to navigate weeks. Beautiful. Functional. Almost too easy.

Drag and Drop Between Days

The whole point of week view is being able to look at your week and go “actually, this task should be Thursday, not Tuesday.” So I needed drag-and-drop between days.

Used the same HTML5 drag API from the daily view:

const [draggedTask, setDraggedTask] = useState<Task | null>(null);

const handleDragStart = (task: Task) => {
  setDraggedTask(task);
};

const handleDrop = (date: Date) => {
  if (!draggedTask) return;
  
  const newPlannedDate = date.toISOString().split('T')[0];
  
  updateTaskMutation.mutate({
    id: draggedTask.id,
    data: { plannedDate: newPlannedDate }
  });
  
  setDraggedTask(null);
};

// On each day column:
<div
  onDragOver={(e) => e.preventDefault()}
  onDrop={() => handleDrop(date)}
>
  {tasks.map(task => (
    <div
      draggable
      onDragStart={() => handleDragStart(task)}
    >
      {task.title}
    </div>
  ))}
</div>

Grabbed a task, dragged it to another day, and… it worked? No! Of course not. This is web development.

Timezone Hell: A Three-Act Tragedy

Act I: The Mysterious Monday

Created a task for Tuesday. Checked week view. It showed up on Monday.

Excuse me?

Act II: The Midnight Betrayal

The issue: JavaScript’s Date objects are timezone-aware, and I’m in EST. When I set a task for “Tuesday”, I was creating a Date like “2025-11-12”, which JavaScript helpfully interprets as “2025-11-12T00:00:00 UTC” (midnight UTC). But when I compare it to “today” in my local timezone at 8pm, UTC thinks it’s already tomorrow.

Date comparisons were converting everything to UTC and getting confused about what day it actually was.

Act III: String Comparison Saves The Day

The fix: Stop creating Date objects. Compare the date strings directly:

const getTasksForDay = (date: Date): Task[] => {
  const targetDate = date.toISOString().split('T')[0]; // "2025-11-05"
  
  return allTasks.filter(task => {
    if (!task.plannedDate) return false;
    const taskDate = task.plannedDate.split('T')[0]; // "2025-11-05"
    return taskDate === targetDate;
  });
};

Just compare “2025-11-05” with “2025-11-05”. No timezone conversions. No midnight betrayals. Problem solved.

But wait, there’s more! The isToday function in the daily view had the same bug:

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

// New (actually works):
const isToday = (dateString: string | null) => {
  if (!dateString) return false;
  const taskDate = dateString.split('T')[0];
  
  // Get today in LOCAL timezone
  const today = new Date();
  const year = today.getFullYear();
  const month = String(today.getMonth() + 1).padStart(2, '0');
  const day = String(today.getDate()).padStart(2, '0');
  const todayDate = `${year}-${month}-${day}`;
  
  return taskDate === todayDate;
};

This bug bit me THREE TIMES. Timezones are a scourge upon humanity.

The Unplanned Tasks Problem

Week view worked. Drag-and-drop worked. But I found a UX issue: to get a task into week view, I had to first schedule it for “today” in the Tasks view, then move it to the actual day I wanted.

That’s stupid.

Added an “Unplanned Tasks” section at the top of week view: a horizontal bar showing all tasks without a plannedDate:

const unplannedTasks = allTasks.filter(task => !task.plannedDate);

return (
  <div>
    {/* Unplanned section */}
    {unplannedTasks.length > 0 && (
      <div className="mb-6 bg-gray-50 border-dashed border-2 rounded-lg p-4">
        <h3 className="text-sm font-semibold mb-3">
          Unplanned Tasks ({unplannedTasks.length})
        </h3>
        <div className="flex flex-wrap gap-2">
          {unplannedTasks.map(task => (
            <div
              key={task.id}
              draggable
              onDragStart={() => handleDragStart(task)}
              className="bg-white border rounded px-3 py-2 cursor-move"
            >
              {task.title}
            </div>
          ))}
        </div>
      </div>
    )}
    
    {/* Week grid */}
    <div className="grid grid-cols-7 gap-4">
      {/* ... */}
    </div>
  </div>
);

Now I could drag tasks directly from the unplanned pool onto any day. Much better workflow.

Recurring Tasks: The Feature That Got Complicated

You know what sucks? Having to manually create “Morning workout” every single day. Or “Weekly planning” every Monday. Recurring tasks were a must-have.

But how do you implement recurring tasks?

Option 1: Single Task That Resets

One task that marks itself incomplete when the recurrence period hits. Simple, but you lose history and can’t plan ahead.

Option 2: Template + Instances

Create a “template” task that generates individual task instances. Each instance is independent – you can complete one without affecting the others, edit them individually, and see your history.

I went with Option 2 because I’m not building a toy app.

The Schema

Added recurring fields to the Task model:

model Task {
  // ... existing fields ...
  
  isRecurring        Boolean  @default(false)
  recurrencePattern  String?  // "daily", "weekdays", "weekly", "custom"
  recurrenceDays     String?  // JSON: ["MON","WED","FRI"]
  recurrenceInterval Int?     // For custom patterns
  
  // Self-referential relation
  parentTaskId       String?
  parentTask         Task?    @relation("Recurrence", fields: [parentTaskId], references: [id], onDelete: Cascade)
  instances          Task[]   @relation("Recurrence")
}

Templates have isRecurring = true and no plannedDate. Instances have a parentTaskId pointing to their template and a plannedDate for when they’re scheduled.

The UI

Updated the Create Task modal with recurring options:

<div className="border-t pt-4">
  <label className="flex items-center gap-2">
    <input
      type="checkbox"
      checked={isRecurring}
      onChange={(e) => setIsRecurring(e.target.checked)}
    />
    <span>Recurring Task</span>
  </label>

  {isRecurring && (
    <div className="mt-4 space-y-3 bg-gray-50 p-3 rounded-md">
      <select
        value={recurrencePattern}
        onChange={(e) => setRecurrencePattern(e.target.value)}
      >
        <option value="daily">Daily</option>
        <option value="weekdays">Weekdays (Mon-Fri)</option>
        <option value="weekly">Weekly (specific days)</option>
        <option value="custom">Custom interval</option>
      </select>
      
      {recurrencePattern === 'weekly' && (
        <div className="flex gap-2">
          {['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'].map(day => (
            <button
              key={day}
              type="button"
              onClick={() => toggleDay(day)}
              className={recurrenceDays.includes(day) 
                ? 'bg-blue-600 text-white' 
                : 'bg-white border'}
            >
              {day}
            </button>
          ))}
        </div>
      )}
    </div>
  )}
</div>

You can pick:

  • Daily – Every day
  • Weekdays – Monday through Friday
  • Weekly – Specific days (like Mon/Wed/Fri for gym days)
  • Custom – Every N days

Generating Instances

The tricky part: when do you create the instances?

I wrote a generator function that takes a template and date range, then creates instances based on the pattern:

export function generateInstances(
  template: RecurringTemplate,
  startDate: Date,
  endDate: Date
): TaskInstance[] {
  const instances: TaskInstance[] = [];
  const current = new Date(startDate);
  
  while (current <= endDate) {
    let shouldCreate = false;
    
    switch (template.recurrencePattern) {
      case 'daily':
        shouldCreate = true;
        break;
        
      case 'weekdays':
        const day = current.getDay();
        shouldCreate = day >= 1 && day <= 5; // Mon-Fri
        break;
        
      case 'weekly':
        if (template.recurrenceDays) {
          const selectedDays = JSON.parse(template.recurrenceDays);
          const dayNames = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'];
          const currentDayName = dayNames[current.getDay()];
          shouldCreate = selectedDays.includes(currentDayName);
        }
        break;
        
      case 'custom':
        const daysSinceStart = Math.floor(
          (current.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)
        );
        shouldCreate = daysSinceStart % template.recurrenceInterval === 0;
        break;
    }
    
    if (shouldCreate) {
      instances.push({
        title: template.title,
        description: template.description,
        timeEstimate: template.timeEstimate,
        plannedDate: new Date(current),
        userId: template.userId,
        parentTaskId: template.id,
      });
    }
    
    current.setDate(current.getDate() + 1);
  }
  
  return instances;
}

Added a backend endpoint to generate instances:

router.post('/:id/generate-instances', async (req, res) => {
  const { id } = req.params;
  const { weeks = 2 } = req.body;
  
  const template = await prisma.task.findUnique({ where: { id } });
  
  if (!template.isRecurring) {
    return res.status(400).json({ error: 'Not a recurring template' });
  }
  
  const startDate = new Date();
  const endDate = new Date();
  endDate.setDate(endDate.getDate() + (weeks * 7));
  
  const instances = generateInstances(template, startDate, endDate);
  
  // Check for existing instances to avoid duplicates
  const existingInstances = await prisma.task.findMany({
    where: {
      parentTaskId: template.id,
      plannedDate: { gte: startDate, lte: endDate },
    },
  });
  
  const existingDates = new Set(
    existingInstances.map(t => t.plannedDate?.toISOString().split('T')[0])
  );
  
  const newInstances = instances.filter(
    inst => !existingDates.has(inst.plannedDate.toISOString().split('T')[0])
  );
  
  await prisma.task.createMany({ data: newInstances });
  
  res.json({ message: `Generated ${newInstances.length} instances` });
});

Auto-Generation

When you create a recurring task, it automatically generates instances for the next 2 weeks:

const createMutation = useMutation({
  mutationFn: taskApi.createTask,
  onSuccess: async (data) => {
    queryClient.invalidateQueries({ queryKey: ['tasks'] });
    
    if (data.isRecurring) {
      await taskApi.generateInstances(data.id, 2); // 2 weeks
      queryClient.invalidateQueries({ queryKey: ['tasks'] });
    }
  }
});

Create a “Morning workout” task set to daily, and boom – 14 instances appear in your week view.

The TODO I’m Ignoring For Now

Right now, “All Tasks” shows every single instance. If you have a daily task, that’s 14 copies cluttering your list. Not ideal.

The plan: show only the most recent uncompleted instance. When you complete it, the next one appears. Like a hydra, but for productivity.

But that’s a problem for tomorrow-me. Today-me is calling this feature “done enough.”

What’s Next

The app is actually usable now. I can:

  • See my whole week
  • Drag tasks between days
  • Set up recurring tasks
  • Track time with Pomodoro

What’s missing? Probably authentication at some point. Maybe some analytics. Definitely need to fix that “All Tasks” view showing 47 instances of “Morning workout.”

But for now, I’m going to actually use this thing instead of just building it.


Part 4 Complete. Part 5 will cover… honestly, I don’t know yet. Probably authentication because using temp-user-id forever is getting embarrassing.

Lines of code written: Too many. Timezone bugs encountered: Three. Timezones hated: All of them.

Leave a comment