Building software, breaking things, and writing down what I learned.


Category: App Development

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

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

    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.

  • Building a Sunsama Clone: Part 3 – The Pomodoro Saga

    Building a Sunsama Clone: Part 3 – The Pomodoro Saga

    Or: How I Learned That Timer State Management is Harder Than It Looks

    Part 3 of an ongoing series documenting my journey building a production-ready task management app. Read Part 1 and Part 2 to catch up on the foundation and daily planning features.


    If you’ve ever thought “adding a timer should be easy,” let me tell you a story. Spoiler: it wasn’t.

    The Goal

    I wanted something simple: click a tomato icon on any scheduled task, get a 25-minute Pomodoro timer, track actual time spent. Compare estimates to reality. Standard productivity app stuff.

    Turns out “simple” is doing a lot of heavy lifting in that sentence.

    Session 1: The Foundation (aka “This Should Be Easy”)

    I started optimistic. Add an actualTime field to my Prisma schema, create a PomodoroTimer component, slap a tomato button on scheduled tasks. How hard could it be?

    // Added to schema
    actualTime Int? @default(0)

    Built a basic timer component:

    • 25-minute countdown (1500 seconds)
    • Play/pause buttons
    • Shows time in MM:SS format
    • Opens in a modal

    Hit save. Tested it. It worked!

    …for about 30 seconds before I found the first bug.

    The Problems Start Rolling In

    Problem 1: The Disappearing Timer

    Close the modal, reopen it. Timer reset to 25:00.

    Oh.

    The timer state was living entirely inside the modal component. Close modal = destroy component = lose state. Classic React gotcha.

    Problem 2: The Ghost Timer

    Start a timer, switch to a different task, click its timer button. Now I have two timers running simultaneously, both ticking away, both thinking they’re the only timer in existence.

    Multiple intervals firing. Time being logged to the wrong tasks. Chaos.

    Problem 3: The Vanishing Progress

    Pause a timer, close the modal without stopping. That progress? Gone. Lost to the void. No time logged.

    Not great when you’re trying to track actual work.

    Problem 4: The Button Wars

    Added the tomato button to scheduled tasks in the timeline. Forgot I already had drag handles on those tasks.

    Tried to drag a task = accidentally started a timer.
    Tried to start a timer = accidentally dragged the task.

    The drag-and-drop library and the click handler were fighting each other. My task cards had become a UX nightmare.

    The Refactor: Centralized State Management

    After a day of whack-a-mole with bugs, I realized the core issue: timer state was everywhere and nowhere at the same time.

    State in the modal component. State in the parent. Intervals scattered across multiple files. No single source of truth.

    Time to fix this properly.

    Enter Zustand

    I was already using Zustand for settings, so I created a dedicated timer store:

    // timerStore.ts
    interface TimerState {
    activeTask: Task | null;
    timeLeft: number;
    isRunning: boolean;
    startTimer: (task: Task) => void;
    pauseTimer: () => void;
    resumeTimer: () => void;
    stopTimer: () => void;
    tick: () => void;
    }

    One store. One source of truth. One interval to rule them all.

    The Logic Flow

    1. Click tomato → Check if a timer exists for this task
    2. If yes, just open the modal (don’t reset!)
    3. If no, create new timer with fresh 25:00
    4. If different task, warn user (or auto-stop previous timer)
    const handleStartTimer = (task: Task) => {
    // Don't reset timer if already exists for this task
    if (activeTimer && activeTimer.task.id === task.id) {
    setShowTimerModal(true);
    return;
    }
    // Starting timer for a different task
    setActiveTimer({
    task,
    timeLeft: 1500,
    isRunning: false,
    totalTimeSpent: 0,
    });
    setShowTimerModal(true);
    };

    The Background Timer Challenge

    Next problem: the timer needs to keep running when the modal is closed. Users shouldn’t have to keep the modal open for 25 minutes.

    The Floating Timer Button

    Created a small indicator that appears when a timer is active:

    {activeTimer && (
    <button
    onClick={() => setShowTimerModal(true)}
    className="fixed bottom-4 right-4 bg-red-500 text-white px-4 py-2 rounded-full"
    >
    🍅 {formatTime(activeTimer.timeLeft)}
    </button>
    )}

    Now you can see the countdown even with the modal closed. Click it to reopen and pause/resume.

    The Interval Management

    This is where it got tricky. The interval needs to:

    • Run in the background even when modal is closed
    • Stop properly when timer completes
    • Clean up when component unmounts
    • Update the floating button in real-time
    useEffect(() => {
    if (activeTimer && activeTimer.isRunning && activeTimer.timeLeft > 0) {
    const intervalId = setInterval(() => {
    setActiveTimer(prev => {
    if (!prev) return null;
    const newTimeLeft = prev.timeLeft - 1;
    // Timer finished!
    if (newTimeLeft <= 0) {
    const minutesSpent = Math.ceil((1500 + prev.totalTimeSpent) / 60);
    handleTimerComplete(prev.task.id, minutesSpent);
    return null;
    }
    return { ...prev, timeLeft: newTimeLeft };
    });
    }, 1000);
    return () => clearInterval(intervalId);
    }
    }, [activeTimer?.isRunning, activeTimer?.timeLeft]);

    The dependencies on this useEffect are critical. If you miss activeTimer?.isRunning, the interval won’t restart properly after a pause. If you miss activeTimer?.timeLeft, you get stale closures.

    Ask me how I know.

    The Backend Bug

    Got everything working in the UI. Time to test saving actualTime to the database.

    Hit “Stop & Save Progress.” Check the database. actualTime is still 0.

    What.

    Debug time. Console logs everywhere. Request is being sent. Response looks good. But database isn’t updating.

    Checked the network tab. Payload includes actualTime: 15. Server responds 200 OK.

    …wait, let me check the backend route.

    // server/src/routes/tasks.ts
    router.patch('/:id', async (req, res) => {
    const { title, timeEstimate, date, plannedTime } = req.body;
    // ^^^
    // WHERE'S actualTime?!
    });

    THERE IT IS.

    The PATCH endpoint was accepting actualTime in the request, storing it in memory, responding with it… but never actually writing it to the database because it wasn’t in the destructured body.

    One line fix:

    const { title, timeEstimate, date, plannedTime, actualTime } = req.body;

    And suddenly, everything worked.

    The “Why Isn’t It Pausing?” Bug

    Thought I was done. Then I tested the full flow:

    1. Start timer ✅
    2. Let it run for a bit ✅
    3. Pause ✅
    4. Close modal ✅
    5. Reopen modal… timer is running again? ❌

    The pause state wasn’t persisting when I closed the modal.

    The Subtle Bug

    My pauseTimer function was doing too much:

    pauseTimer: () => {
    const timeElapsed = 1500 - timeLeft;
    set({
    isRunning: false,
    totalTimeSpent: totalTimeSpent + timeElapsed,
    timeLeft: 1500, // ← RESET TO 25:00?!
    });
    }

    I was resetting timeLeft to 1500 on every pause. The idea was to track “time spent across sessions,” but it broke the basic pause/resume flow.

    User pressed pause at 15:23? Should stay at 15:23. Not reset to 25:00.

    Fixed version:

    pauseTimer: () => {
    set({ isRunning: false });
    // That's it. Just pause. Don't touch timeLeft.
    }

    Sometimes the best fix is deleting code.

    The Drag Handle Solution

    Remember the button wars? Timer button fighting with drag handles?

    Solution: move the drag handle to a more specific target.

    // Instead of the entire task card being draggable:
    <div {...listeners} {...attributes}>
    {/* Entire task card */}
    </div>
    // Make only a specific handle draggable:
    <div className="task-card">
    <div {...listeners} {...attributes} className="drag-handle">
    ⋮⋮
    </div>
    <button onClick={() => startTimer(task)}>🍅</button>
    </div>

    Now you can drag the task by grabbing the handle, and start the timer by clicking the tomato. No more conflicts.

    The Daily Summary Update

    With actualTime tracking working, I updated the daily summary to show reality vs. estimates:

    const totalMinutes = tasks.reduce((sum, task) =>
    sum + (task.timeEstimate || 0), 0
    );
    const actualMinutes = tasks.reduce((sum, task) =>
    sum + (task.actualTime || 0), 0
    );

    Now the summary shows:

    • Total Time (estimated): 4h 30m
    • Actual Time (from Pomodoros): 3h 45m

    That purple “Actual Time” number hits different when you realize you estimated 4.5 hours but only worked 3.75.

    What I Learned

    1. State Management Is Non-Trivial

    “Just add a timer” quickly becomes “completely refactor your state architecture.”

    Centralizing state early would have saved me two days of debugging.

    2. Background Timers Are Tricky

    Intervals, cleanup functions, stale closures, re-renders affecting timing… there’s a reason timer libraries exist.

    Getting this right requires thinking through every edge case:

    • What if the user closes the tab?
    • What if they pause and immediately resume?
    • What if they start a timer, then schedule the task for a different time?

    3. Pausing Should Just Pause

    When I wrote pauseTimer to do time calculations and resets, I was overthinking it.

    Pause means “stop running.” Nothing more. The simpler the function, the fewer bugs.

    4. Backend Validation Matters

    I was so focused on the frontend timer logic, I forgot to double-check the backend was actually saving everything.

    Always verify the full request/response cycle. Console.log is your friend.

    5. Test The Unhappy Paths

    I tested “start timer, let it complete” a hundred times.

    Know what I didn’t test enough? Pausing midway through, closing the modal, switching tasks, and reopening.

    The bugs live in the paths you don’t test.

    The Final Implementation

    After all the refactoring, the flow is clean:

    1. Click tomato on a task → Opens modal with 25:00 countdown
    2. Click Start → Timer begins, modal can be closed
    3. Floating button shows → Live countdown visible anywhere
    4. Pause anytime → Timer stops but remembers position
    5. Close modal → Timer keeps running in background
    6. Timer completes → Logs actual time to database
    7. Daily summary updates → Shows estimated vs actual time

    It works. Finally.

    Next Up

    The Pomodoro timer works. Could I add break reminders, multiple durations, sound notifications? Sure. But that’s polish for later.

    Right now, I can only plan one day at a time. I need to see the whole week. Plan Monday while looking at what’s coming Tuesday. Move tasks around without switching between day views.

    Week view is next. Time to build the 7-day planning interface that makes this actually useful.

    The timer features (breaks, notifications, history) are going in the backlog. They’ll be great additions eventually, but they’re not what’s blocking me from using this app daily.

    Ironically, I didn’t use the Pomodoro timer while building the Pomodoro timer. Too meta.


    Part 3 Complete. Part 4 will cover building the week view – drag tasks across days, see patterns, plan ahead.

    Stay tuned for more adventures in overengineering task management! 🍅

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

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

    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.

  • Building a Sunsama Clone: Part 1 – The Foundation

    Building a Sunsama Clone: Part 1 – The Foundation

    Or: How I Spent 3 Hours Avoiding My Actual To-Do List by Building a Better To-Do List

    This is part 1 of an ongoing series where I document building a production-ready task management app from scratch. Follow along for the technical deep dives, architectural decisions, and inevitable “well, that didn’t work” moments.

    The Problem (AKA My Life)

    Look, I’m a senior software engineer. I can architect distributed systems, debug race conditions in my sleep, and explain monads to junior devs without making them cry. But ask me to remember to buy groceries? Absolute chaos.

    I’ve tried everything. Notion (too many features), Todoist (too simple), bullet journaling (let’s not talk about it), and those fancy productivity apps that cost more than my monthly coffee budget. Then I discovered Sunsama and thought, “This is it! Time blocking! Daily planning! This will fix me!”

    It did not fix me. But it did inspire me to spend a Sunday building my own version, which is honestly the most on-brand thing I’ve ever done.

    The Stack (Let’s Talk Architecture)

    Frontend: React 18 + TypeScript + Vite

    Why React 18? Honestly, muscle memory. But also: concurrent rendering, automatic batching, and Suspense support that I’m not using yet but plan to. The new useId hook is going to be clutch when I build the drag-and-drop interface.

    Why TypeScript? Because I like sleeping at night. Also because catching “Property ‘title’ does not exist on type ‘Task’” at build time instead of in production is worth the extra keystrokes.

    Why Vite? Have you seen how fast it is? I hit save and the page updates before my eyes can refocus. Coming from Create React App, this is like going from dial-up to fiber. Plus, the default config just works. No ejecting, no rewiring, no webpack.config.js that looks like ancient runes.

    // vite.config.ts - that's it, that's the whole config
    import { defineConfig } from 'vite'
    import react from '@vitejs/plugin-react'
    export default defineConfig({
      plugins: [react()],
    })
    

    Frontend State Management: React Query + Zustand

    Here’s where it gets interesting. I’m using two state management solutions, and before you @ me, hear me out:

    React Query for server state – anything that comes from the API. Tasks, users (eventually), all of it. React Query handles caching, refetching, optimistic updates, the works.

    Zustand for client state – UI state that doesn’t touch the server. Think: “is the sidebar open?” or “which filter is active?”. I haven’t needed it much yet, but it’s installed and ready for when I do.

    The key insight: server state and client state are fundamentally different problems. Server state is async, can be stale, needs caching. Client state is synchronous, always fresh, no caching needed. Using different tools for different jobs = chef’s kiss.

    Backend: Node.js + Express + TypeScript + Prisma

    Express in 2025? Yes. Fight me. It’s boring, but it works, everyone knows it. I don’t need GraphQL subscriptions for a task app. RESTful routes are fine.

    Why Prisma? Three reasons:

    1. Type safety end-to-end (TypeScript on both sides talking to the same schema)
    2. Migrations that don’t make me cry
    3. This query syntax:
    const tasks = await prisma.task.findMany({
        where: { 
            userId,
            completed: false,
        },
        include: {
            user: true  // Automatic joins!
        },
        orderBy: { createdAt: 'desc' },
    });

    Compare that to writing raw SQL with string interpolation. Yeah, I’ll take the ORM.

    PostgreSQL via Supabase – I wanted Postgres (because it’s solid and has proper data types), but I didn’t want to run a database locally. Supabase gives me:

    • Hosted Postgres
    • Connection pooling
    • Automatic backups
    • A nice dashboard
    • Free tier that’s actually usable

    Coming in future posts: I’ll probably regret not using their auth system and will migrate to it eventually.

    The Monorepo Setup

    sunsama-clone/
    ├── client/          # React app
    ├── server/          # Express API
    └── package.json     # Root workspace config

    Using npm workspaces (not Lerna, not Turborepo, just npm) because:

    • It’s built into npm 7+
    • Dependencies hoist automatically
    • Scripts are easy: npm run dev --workspace=client
    • No extra configuration needed

    The root package.json just has:

    {
      "workspaces": ["client", "server"]
    }

    That’s it. Magic. Dependencies get installed to the root node_modules, each workspace has its own package.json for its specific deps. Clean, simple, works.

    The Build: A Series of Unfortunate Events

    Chapter 1: Docker, Why Have You Forsaken Me?

    Started with grand plans for a local PostgreSQL setup in Docker. The dream: docker-compose up and boom, perfect dev environment.

    Here’s what my docker-compose.yml looked like:

    version: '3.8'
    services:
      postgres:
        image: postgres:16
        environment:
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: sunsama_clone
        ports:
          - "5432:5432"

    Looks good, right? WRONG.

    Prisma refused to connect. Every error message was some variation of:

    • P1010: User 'postgres' does not exist
    • ECONNREFUSED
    • password authentication failed
    • the pain, the pain of it all

    Things I tried:

    1. Different connection strings (at least 15 variations)
    2. Adding a shadow database URL (Prisma’s docs suggested it)
    3. Waiting longer for Postgres to initialize (maybe it just needed time?)
    4. Checking Postgres logs (they said everything was fine, lies)
    5. Googling increasingly desperate phrases
    6. Questioning my career choices

    Here’s what I think was happening: Prisma was trying to connect before Postgres finished initializing, even with healthchecks. Or maybe there was a network issue between containers. Or maybe Docker was gaslighting me.

    The pragmatic decision: After dealing with this for entirely too long, I opted for Supabase, got a connection string, and it worked in 30 seconds.

    # The magic string that actually worked
    DATABASE_URL="postgresql://postgres:[password]@db.[project].supabase.co:6543/postgres?pgbouncer=true"
    DIRECT_URL="postgresql://postgres:[password]@db.[project].supabase.co:5432/postgres"
    

    Why this was the right call:

    • Dev/prod parity (I’ll use Supabase in production anyway)
    • No local Docker containers eating my battery
    • Connection pooling built-in
    • Automatic backups
    • I can access the database from anywhere

    Lesson learned: Sometimes “works on my machine” isn’t the goal. Sometimes “works, period” is enough. I’ll tackle Docker setup in a future post when I actually need local development isolation. For now, pragmatism wins.

    Coming in Part 2: I’ll probably set up Docker properly because I can’t help myself.

    Chapter 2: Tailwind v4 – A New Hope (But Different)

    Tailwind v4 dropped and changed… everything? Here’s what’s different:

    The Old Way (v3):

    // tailwind.config.js
    module.exports = {
      content: ['./src/**/*.{js,ts,jsx,tsx}'],
      theme: { extend: {} },
      plugins: [],
    }

    The New Way (v4):

    // postcss.config.js
    export default {
        plugins: {
            '@tailwindcss/postcss': {},
        },
    }

    And in your CSS:

    /* index.css */
    @import "tailwindcss";
    

    That’s it. No config file. No content paths. No theme object. It just… works.

    How it works: Tailwind v4 uses a new engine that scans your files automatically. It’s faster, the bundle is smaller, and the API is cleaner. The tradeoff? Breaking changes if you’re migrating from v3.

    The gotcha I hit: I tried to use @tailwindcss/forms plugin. Doesn’t exist in v4 yet. Had to style form elements manually, which honestly wasn’t terrible:

    input, textarea {
        @apply px-3 py-2 border border-gray-300 rounded-md;
        @apply focus:ring-2 focus:ring-blue-500 focus:border-blue-500;
    }

    Verdict: Once you get past the initial “wait, where’s my config file?” panic, it’s actually really nice. Less configuration, faster builds, same utility-first goodness.

    Coming in a future post: Custom design system with Tailwind v4’s new theming approach.

    Chapter 3: npm Workspaces Are Actually Cool?

    I set up a monorepo because I wanted client and server in the same repo without going full Turborepo mode. npm workspaces just… worked? Dependencies hoisted to the root, proper isolation, life was good.

    I was waiting for something to break. Nothing broke. This never happens. I’m still suspicious.

    Chapter 4: GitHub

    Created the repo and pushed my code. Self-explanatory. Took 2 minutes. Was this worth a chapter? Probably not.

    What Actually Works (The Good Part!)

    After all that setup chaos, I ended up with a fully functional CRUD app in about 3 hours total:

    ✅ Create tasks with titles, descriptions, and due dates
    ✅ Check off tasks (so satisfying)
    ✅ Delete tasks (even more satisfying)
    ✅ Real-time updates thanks to React Query
    ✅ Clean UI that doesn’t make my eyes bleed
    ✅ Mobile responsive

    The app works! I can add tasks! I can finish them! I won’t actually use it to complete tasks because that would require discipline, but technically I could.

    The Code: Deep Dives Into What Actually Works

    React Query: The Secret Sauce

    React Query handles all the annoying parts of data fetching. Here’s the full setup:

    // main.tsx - Query client configuration
    const queryClient = new QueryClient({
        defaultOptions: {
            queries: {
                refetchOnWindowFocus: false,  // Don't refetch when I tab back
                retry: 1,                      // Try twice, then give up
                staleTime: 5 * 60 * 1000,     // Data fresh for 5 minutes
            },
        },
    })

    Why these settings?

    • refetchOnWindowFocus: false – I’m tabbing between my editor and browser constantly. Don’t need to refetch every time.
    • retry: 1 – If the API is down, retrying 3 times just delays the error message.
    • staleTime: 5 minutes – Tasks don’t change that often. No need to hammer the API.

    The Query Hook:

    const { data: tasks, isLoading, error } = useQuery({
        queryKey: ['tasks'],
        queryFn: taskApi.getTasks,
    });

    That queryKey is the secret. React Query uses it to cache results. If I call this hook in multiple components, they all share the same cached data. Change the key, you get a new cache entry:

    // Different cache entries
    ['tasks']                    // All tasks
    ['tasks', { completed: true }]  // Completed tasks only
    ['tasks', userId]            // User-specific tasks

    The Mutation Hooks:

    const createMutation = useMutation({
        mutationFn: taskApi.createTask,
        onSuccess: () => {
            // Invalidate queries to trigger refetch
            queryClient.invalidateQueries({ queryKey: ['tasks'] });
        },
    });
    const deleteMutation = useMutation({
        mutationFn: taskApi.deleteTask,
        onSuccess: () => {
            queryClient.invalidateQueries({ queryKey: ['tasks'] });
        },
    });

    What’s happening here:

    1. Call createMutation.mutate(newTask)
    2. API request fires
    3. On success, invalidate the ['tasks'] cache
    4. React Query automatically refetches all queries using that key
    5. UI updates with new data

    No manual state updates, no Redux actions, no event emitters. Just invalidate the cache and React Query does the rest.

    Future optimization: Right now I’m invalidating the whole list on every mutation. Later, I’ll implement optimistic updates:

    // Coming in Part 3
    const createMutation = useMutation({
        mutationFn: taskApi.createTask,
        onMutate: async (newTask) => {
            // Cancel outgoing queries
            await queryClient.cancelQueries({ queryKey: ['tasks'] });
            
            // Snapshot previous value
            const previous = queryClient.getQueryData(['tasks']);
            
            // Optimistically update
            queryClient.setQueryData(['tasks'], (old) => [...old, newTask]);
            
            return { previous };
        },
        onError: (err, newTask, context) => {
            // Rollback on error
            queryClient.setQueryData(['tasks'], context.previous);
        },
    });

    This makes the UI feel instant – the task appears immediately, then syncs with the server in the background.

    Prisma: Type-Safe Database Access

    Prisma’s magic is in the generated client. Here’s my schema:

    model Task {
      id          String    @id @default(uuid())
      title       String
      description String?
      completed   Boolean   @default(false)
      dueDate     DateTime?
      createdAt   DateTime  @default(now())
      updatedAt   DateTime  @updatedAt
      
      userId String
      user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
    }

    Run npx prisma generate and it creates a fully-typed client:

    // This knows about Task, knows completed is Boolean,
    // knows description is String | null, everything
    const task = await prisma.task.create({
        data: {
            title: "New task",
            userId: "temp-user-id",
            // TypeScript will yell if I forget required fields
            // or pass wrong types
        }
    });

    The Relations:

    // Get tasks with user data in one query
    const tasks = await prisma.task.findMany({
        include: {
            user: true  // Joins the User table automatically
        }
    });
    // Type of tasks is now:
    // Array<Task & { user: User }>

    Prisma handles the JOIN, returns properly typed data, and I don’t write SQL.

    Prisma Migrations:

    # Create a migration
    npx prisma migrate dev --name add_task_priority
    # It generates SQL for me:
    # CREATE TABLE "Task" (
    #     "id" TEXT NOT NULL PRIMARY KEY,
    #     "title" TEXT NOT NULL,
    #     ...
    # )

    The migration files are in prisma/migrations/ as SQL. I can version control them, review them, modify them if needed. But 99% of the time, Prisma gets it right.

    Future posts will cover:

    • Complex Prisma queries (aggregations, groupBy)
    • Database indexing strategy
    • Handling concurrent updates
    • Soft deletes vs hard deletes

    API Client: Axios with Interceptors

    The API client has some sneaky stuff in it:

    // client.ts
    export const apiClient = axios.create({
        baseURL: API_BASE_URL,
        headers: {
            'Content-Type': 'application/json',
        },
    });
    // Request interceptor - adds auth token
    apiClient.interceptors.request.use((config) => {
        const token = localStorage.getItem('auth_token');
        if (token) {
            config.headers.Authorization = `Bearer ${token}`;
        }
        return config;
    });
    // Response interceptor - handles 401s
    apiClient.interceptors.response.use(
        (response) => response,
        (error) => {
            if (error.response?.status === 401) {
                localStorage.removeItem('auth_token');
                window.location.href = '/login';
            }
            return Promise.reject(error);
        }
    );
    

    Why interceptors?

    • Don’t have to manually add auth headers to every request
    • Global error handling – one place for 401 logic
    • Easy to add logging, retry logic, etc.

    The API functions:

    // tasks.ts
    export const taskApi = {
        getTasks: async (): Promise<Task[]> => {
            const response = await apiClient.get('/api/tasks');
            return response.data;
        },
        
        createTask: async (data: CreateTaskInput): Promise<Task> => {
            const response = await apiClient.post('/api/tasks', data);
            return response.data;
        },
    };

    Clean, typed, testable. Each function is a single responsibility. Later I can mock these for tests, or swap axios for fetch, or add retry logic.

    Coming in Part 2: Adding authentication with JWT tokens and refresh token rotation.

    Component Architecture: Keeping It Simple

    I’m using a pretty standard React component structure:

    src/
    ├── api/              # API client and functions
    │   ├── client.ts     # Axios instance + interceptors
    │   └── tasks.ts      # Task-specific API calls
    ├── components/       # Reusable UI components
    │   ├── TaskItem.tsx
    │   └── CreateTaskForm.tsx
    ├── pages/            # Full page views
    │   └── Tasks.tsx     # Main task page
    ├── types/            # TypeScript interfaces
    │   └── task.ts
    └── hooks/            # Custom hooks (empty for now)

    Why this structure?

    • api/ – Keeps all HTTP stuff in one place. Easy to mock for tests.
    • components/ – Reusable, presentational components. Pure functions of props.
    • pages/ – “Smart” components that fetch data and handle business logic.
    • types/ – Shared TypeScript interfaces. One source of truth.

    Component Design Principles:

    1. Presentational vs Container

    // TaskItem.tsx - Presentational (dumb component)
    interface TaskItemProps {
        task: Task;
        onToggle: (id: string) => void;
        onDelete: (id: string) => void;
    }
    export function TaskItem({ task, onToggle, onDelete }: TaskItemProps) {
        // Just renders UI, no data fetching
        return (
            <div>
                <input 
                    type="checkbox"
                    checked={task.completed}
                    onChange={() => onToggle(task.id)}
                />
                {task.title}
            </div>
        );
    }
    // Tasks.tsx - Container (smart component)
    export function Tasks() {
        // Handles data fetching and mutations
        const { data: tasks } = useQuery({
            queryKey: ['tasks'],
            queryFn: taskApi.getTasks,
        });
        
        const toggleMutation = useMutation({
            mutationFn: taskApi.toggleTask,
            onSuccess: () => {
                queryClient.invalidateQueries({ queryKey: ['tasks'] });
            },
        });
        
        return (
            <div>
                {tasks?.map(task => (
                    <TaskItem 
                        task={task}
                        onToggle={toggleMutation.mutate}
                        onDelete={deleteMutation.mutate}
                    />
                ))}
            </div>
        );
    }

    Why separate them?

    • Easy to test (presentational components are pure functions)
    • Easy to reuse (TaskItem doesn’t care where data comes from)
    • Easy to reason about (data logic is in pages, UI logic is in components)

    2. Form Handling – Controlled Components

    // CreateTaskForm.tsx
    const [title, setTitle] = useState('');
    const [description, setDescription] = useState('');
    const handleSubmit = (e: React.FormEvent) => {
        e.preventDefault();
        
        onSubmit({
            title: title.trim(),
            description: description.trim() || undefined,
        });
        
        // Reset form
        setTitle('');
        setDescription('');
    };

    Classic controlled components. The form state lives in React state, gets cleared on submit. Simple, predictable.

    Future improvements:

    • Form validation library (probably React Hook Form)
    • Loading states and error messages
    • Keyboard shortcuts (Cmd+Enter to submit)

    Coming in Part 3: Building a complex drag-and-drop interface and managing that state.

    TypeScript: The Type System Doing Heavy Lifting

    Every piece of data has a type. Here’s the full type system so far:

    // types/task.ts
    export interface Task {
        id: string;
        title: string;
        description: string | null;  // Nullable!
        completed: boolean;
        dueDate: string | null;      // Nullable!
        createdAt: string;
        updatedAt: string;
        userId: string;
    }
    export interface CreateTaskInput {
        title: string;
        description?: string;   // Optional on input
        dueDate?: string;       // Optional on input
    }

    Why two types?

    • Task matches the database schema exactly (includes id, timestamps, userId)
    • CreateTaskInput is what the user provides (no id yet, no timestamps)

    This prevents bugs like:

    // TypeScript error: Property 'id' is required
    const task: Task = {
        title: "New task",
        completed: false,
    };
    // This works fine
    const input: CreateTaskInput = {
        title: "New task",
        description: "Details here",
    };
    

    The Prisma types match exactly:

    // Prisma generates this
    type PrismaTask = {
        id: string;
        title: string;
        description: string | null;
        // ... matches our Task interface
    }
    

    End-to-end type safety: database → API → frontend. Change the schema, and TypeScript will yell at you everywhere that needs updating.

    Coming in future parts: Discriminated unions for different task types, generics for reusable API functions, and advanced TypeScript patterns.

    • Hardcoded user ID: 'temp-user-id' living her best life in production-ish code
    • No error boundaries: If something breaks, the whole app goes down like a house of cards
    • No loading skeletons: Just raw “Loading…” text like it’s 2010
    • No form validation: Beyond required fields, you can put whatever cursed data you want in there
    • Zero tests: I’m a senior engineer who knows better, which makes this worse

    I’ll fix it. Eventually. Maybe. (I won’t.)

    The Technical Debt I’m Acknowledging (For Now)

    Let me be real about what’s not production-ready:

    1. The Hardcoded User ID

    const userId = 'temp-user-id';  // Living her best life in every route

    Every API call uses this. No authentication, no user sessions, nothing. Just vibes.

    Why this is actually okay for now: I’m building the task management features first. Once they work, I’ll add auth. Building auth first would mean testing with fake data anyway. This way, I’m testing with real CRUD operations.

    Coming in Part 2: JWT authentication, refresh tokens, and proper session management.

    2. No Error Boundaries

    If a component throws, the whole app crashes. React 18 has error boundaries, I just… didn’t implement them yet.

    // What I should have
    <ErrorBoundary fallback={<ErrorMessage />}>
        <Tasks />
    </ErrorBoundary>
    // What I actually have
    <Tasks /> // YOLO

    3. Loading States Are Basic

    {isLoading && <p>Loading...</p>}

    No skeletons, no smooth transitions, just plain text. It works but it’s not pretty.

    Future improvement: Loading skeletons with Suspense:

    <Suspense fallback={<TaskListSkeleton />}>
        <TaskList />
    </Suspense>

    4. No Tests

    Zero. Zip. Nada. I know, I know. I’m a senior engineer. I should know better.

    My excuse: I’m still figuring out what I’m building. Once the features stabilize, I’ll add:

    • Unit tests for API functions
    • Component tests with React Testing Library
    • E2E tests with Playwright

    Reality check: I’ll probably add tests when something breaks in production and I get scared.

    5. Form Validation Is Minimal

    <input 
        type="text"
        required  // That's the whole validation
    />

    No character limits, no XSS protection, no sanitization. Just raw faith in humanity.

    Coming soon: React Hook Form + Zod for proper validation:

    const schema = z.object({
        title: z.string().min(1).max(200),
        description: z.string().max(1000).optional(),
        dueDate: z.string().datetime().optional(),
    });

    What’s Next: The Roadmap

    This is Part 1 of an ongoing series. Here’s what’s coming:

    Part 2: Authentication & User Management (Next Post)

    • JWT authentication flow
    • Refresh token rotation
    • Protected routes
    • User registration and login
    • Session persistence
    • “Remember me” functionality

    Technical challenges I’ll dive into:

    • Secure token storage (localStorage vs httpOnly cookies)
    • Handling token expiration gracefully
    • Race conditions with multiple API calls
    • CSRF protection

    Part 3: The Daily Planning View (The Hard Part)

    • Drag and drop tasks into today’s schedule
    • Time blocking interface
    • Visual timeline of the day
    • Calculate total planned time vs available time
    • Conflict detection (overlapping time blocks)

    Technical deep dives:

    • React DnD vs react-beautiful-dnd vs dnd-kit (spoiler: I’ll pick dnd-kit)
    • Handling drag state in React Query
    • Optimistic updates for drag operations
    • Time zone handling (moment.js is dead, long live date-fns)

    Part 4: The Stuff That Makes It Actually Useful

    • Calendar integration (Google Calendar, Outlook)
    • Recurring tasks
    • Task priorities and categories
    • Search and filtering
    • Keyboard shortcuts
    • Dark mode (legally required for developer tools)

    Part 5: Performance & Polish

    • Code splitting and lazy loading
    • Image optimization (when I add avatars)
    • PWA setup (offline support!)
    • Bundle size optimization
    • Lighthouse score improvements
    • Real-time updates with WebSockets or polling

    Part 6: Mobile & Desktop Apps

    • React Native for mobile
    • Electron for desktop
    • Sharing code between web/mobile/desktop
    • Platform-specific features

    Part 7: The Production Stuff

    • Error tracking (Sentry)
    • Analytics (PostHog or Plausible)
    • A/B testing framework
    • Monitoring and alerts
    • Database backups and disaster recovery
    • Deployment pipeline (GitHub Actions)

    Bonus Content (TBD):

    • Building a Chrome extension
    • API rate limiting
    • Billing and subscriptions (Stripe)
    • Team collaboration features
    • Data export/import
    • GDPR compliance

    Each post will be a deep technical dive into that feature, including:

    • Architecture decisions and tradeoffs
    • Code walkthroughs with explanations
    • Things that went wrong and how I fixed them
    • Performance considerations
    • Testing strategies

    Why Document This Journey?

    Selfishly: Writing forces me to understand my decisions. If I can’t explain why I chose React Query over Redux, I don’t understand it well enough.

    Hopefully useful to you: There are a million tutorials on building todo apps. There aren’t many on building a real app with real features, real technical debt, and real architectural decisions.

    I’m documenting:

    • The thought process behind technical choices
    • The things that didn’t work (Docker, I’m looking at you)
    • The pragmatic tradeoffs (Supabase instead of local DB)
    • The “I’ll fix it later” decisions that we all make

    This is how real apps get built. They are built iteratively and imperfectly. There is a lot of Googling involved. Some questionable shortcuts are taken, which you promise you’ll fix before launch (narrator: she did not fix them before launch).

    Lessons From Part 1 (That I’ll Probably Ignore Next Time)

    1. Pragmatism > Perfection

    Docker wasn’t working? Cool, Supabase exists. Ship it. I could’ve spent a week getting the perfect local development environment. Instead, I spent 30 seconds getting a working database.

    The meta-lesson: Done is better than perfect, but “done” doesn’t mean “shipped to production.” It means “working well enough to learn from.”

    2. Boring Technology Is Good Technology

    Express, React, PostgreSQL. Nothing fancy, nothing new. It all just works because thousands of people have already hit the edge cases.

    Future me will thank past me for not choosing the hot new framework that’ll be deprecated in 6 months.

    3. TypeScript Everywhere or TypeScript Nowhere

    Half-typed code is worse than no types. You get a false sense of security. Go full TypeScript or don’t bother.

    // This is a lie - response.data could be anything
    const task = await apiClient.get('/api/tasks') as Task;
    // This is honest - we're validating at runtime
    const task = TaskSchema.parse(await apiClient.get('/api/tasks'));

    Coming in a future post: Runtime validation with Zod to make this bulletproof.

    4. React Query Is Magic (No Really)

    If you’re doing data fetching in React and not using React Query (or SWR or RTK Query), you’re missing out. The amount of boilerplate it eliminates is staggering.

    Before React Query:

    • Manual loading states
    • Manual error handling
    • Manual cache invalidation
    • Manual optimistic updates
    • Manual retry logic
    • Crying

    After React Query:

    const { data, isLoading, error } = useQuery({
        queryKey: ['tasks'],
        queryFn: getTasks,
    });

    5. Build For Real Use Cases

    I’m building this because I need it. Every feature I add, I’m thinking: “Would I actually use this? Does this solve my problem?”

    That focus keeps me from:

    • Over-engineering (do I really need microservices for a task app?)
    • Under-engineering (can’t skip auth forever)
    • Bikeshedding (who cares what the button color is if the app doesn’t work)

    6. Document While Building, Not After

    I’m writing this blog post while the experience is fresh. The Docker pain is still real. The Tailwind confusion is still visceral. The “oh that’s why” moments are still memorable.

    If I waited until the app was done: “I set up the database, it was fine.”

    Writing it now: “I spent too long fighting Docker. I questioned my life choices. Then I set up Supabase and felt both defeated and relieved.”

    The Journey Continues

    This is just the foundation. The app works, but it’s not useful yet. It’s a todo list. The world has enough todo lists.

    What makes Sunsama special is the daily planning flow. It’s the ritual of looking at your tasks. You pick what you’ll do today. You assign time blocks. You see your day laid out visually. That’s where the magic happens.

    Building that is going to be hard. Drag and drop in React is notoriously finicky. Time calculations get weird. State management gets complex. I’m excited.

    What’s Actually Working Right Now

    Let me be clear about where we are:

    ✅ Fully functional:

    • Create tasks with title, description, due date
    • Mark tasks as complete
    • Delete tasks
    • Real-time UI updates
    • Type-safe end-to-end
    • Deployed and accessible (well, running locally)

    ⚠️ Works but needs improvement:

    • No authentication (using temp-user-id)
    • Basic error handling
    • Simple loading states
    • Minimal validation

    ❌ Not built yet:

    • Daily planning view
    • Time blocking
    • Calendar integration
    • Recurring tasks
    • Mobile app
    • Literally everything else

    The app at this stage: A solid foundation with good architecture decisions. Not production-ready, but ready for the next features.

    Next Post Preview

    Part 2: Authentication & User Management is coming next week. I’ll cover:

    • JWT authentication implementation
    • Secure token storage (and why localStorage is controversial)
    • Session management and refresh tokens
    • Protected routes in React Router
    • Migration from temp-user-id to real users

    Technical question I’ll answer: Should you store JWTs in localStorage, sessionStorage, or httpOnly cookies? (Spoiler: everyone has opinions, all of them are confident, and they’re all slightly wrong)

    Follow along:

    • GitHub: [Coming soon – I’ll make the repo public]
    • Twitter: [Your handle if you want]
    • RSS: [If you’re into that]

    Final Thoughts

    I started this project to solve my own task management problems. Three hours later, I have a working CRUD app. I also have a lot of technical debt. I now deeply appreciate how much work goes into “simple” apps.

    The codebase is clean enough to build on. The architecture is solid enough to scale. The features are minimal enough that I’m not overwhelmed. This is the sweet spot.

    Next weekend: Authentication. Then the daily planning view. Then calendar integration. Then… we’ll see.

    The journey from “working app” to “app I actually use every day” is going to be interesting. I’ll document every step. I’ll capture the wins and the failures. I’ll note the moments of “why did I think this was a good idea.” I’ll also capture the surprises of “oh hey, that actually worked.”

    Come along for the ride. It’s going to be educational, occasionally painful, and hopefully entertaining.


    Thanks for reading Part 1! Questions? Suggestions? Think my architecture decisions are terrible? Hit me up – I’m genuinely curious what other engineers would do differently.

    Next post drops next week. Subscribe/follow/whatever kids do these days to get notified.

    P.S. – Yes, I know there are 10,000 task management apps. No, that won’t stop me from building another one. This is the way.

    P.P.S. – The app still doesn’t help me actually complete tasks. But now I can procrastinate in a type-safe, architecturally sound way. Progress?

  • Task Alchemist Development Update: Another Month of Progress

    Task Alchemist Development Update: Another Month of Progress

    The past month has been exciting as I’ve focused on transforming Task Alchemist into a more sophisticated and intuitive task management tool. My goal has always been to optimize task scheduling by blending AI and cognitive psychology insights. Here’s a summary of the latest progress.

    Streamlined Calendar Integration

    One of my key development efforts was improving the calendar integration to seamlessly import external calendars. Specifically, I implemented the feature to import Google Calendar data directly into FullCalendar. Each imported event is tagged with the property source: 'google', ensuring that all external events are easily distinguishable.

    Firebase Tasks Scheduled Around Imported Events

    A significant challenge was enabling Firebase tasks to be scheduled around imported Google Calendar events. This feature required a delicate balance between optimizing productivity and respecting external commitments. I tackled this by calculating priority weighting with a Cloud Function and ensuring that task data is dynamically scheduled in a way that adapts to each user’s existing commitments.

    Reinforcement Learning and Q-Learning

    To achieve my ambitious AI scheduling goals, I’ve been feeding real task data into my reinforcement learning setup, leveraging TaskEnv as the environment. I aim to enable the system to learn from real-world task patterns, ensuring that each schedule recommendation aligns with a user’s natural workflow.

    Preliminary Surveys and Feedback

    The best features are shaped by real-world usage. I conducted preliminary surveys with testers, focusing on features like task creation, list management, calendar scheduling, and Google Calendar imports. The feedback collected has been invaluable for refining preliminary AI scheduling features, helping me identify opportunities for improvement before launching publicly.

    Looking Ahead

    As I move into the next phase of development, I will refine the reinforcement learning algorithms and enhance the overall AI-driven task prioritization framework. I’m also incorporating cognitive psychology insights into the scheduling algorithms to ensure a balance of efficiency and user satisfaction.

    Stay tuned for more updates, and thank you for following my journey with Task Alchemist!